mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 13:01:09 +02:00
chore: fix config conflicts, update README, clean up apps-archived
- Delete .prettierrc (conflicted with .prettierrc.json — kept tabs config) - Delete .env.example (outdated, .env.development is the source of truth) - Rewrite README.md with all 18 active projects (was listing only 4) - Fix CLAUDE.md apps-archived section (listed 11 non-existent apps) - Delete apps-archived/mukke (duplicate of active apps/mukke) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7910737dd9
commit
ea7962501d
65 changed files with 41 additions and 3825 deletions
54
.env.example
54
.env.example
|
|
@ -1,54 +0,0 @@
|
|||
# ==============================================
|
||||
# Mana Core Auth - Environment Variables
|
||||
# ==============================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
|
||||
# Database (PostgreSQL)
|
||||
POSTGRES_DB=manacore
|
||||
POSTGRES_USER=manacore
|
||||
POSTGRES_PASSWORD=your-secure-postgres-password-here
|
||||
|
||||
# Full database URL (used by app)
|
||||
DATABASE_URL=postgresql://manacore:your-secure-postgres-password-here@pgbouncer:6432/manacore
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-secure-redis-password-here
|
||||
|
||||
# JWT Configuration
|
||||
# Generate RS256 key pair:
|
||||
# openssl genrsa -out private.pem 2048
|
||||
# openssl rsa -in private.pem -pubout -out public.pem
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nYOUR_PUBLIC_KEY_HERE\n-----END PUBLIC KEY-----"
|
||||
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END RSA PRIVATE KEY-----"
|
||||
JWT_ACCESS_TOKEN_EXPIRY=15m
|
||||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||
JWT_ISSUER=manacore
|
||||
JWT_AUDIENCE=manacore
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8081,https://yourdomain.com
|
||||
|
||||
# Traefik / SSL
|
||||
ACME_EMAIL=your-email@example.com
|
||||
AUTH_DOMAIN=auth.yourdomain.com
|
||||
|
||||
# Credits Configuration
|
||||
CREDITS_SIGNUP_BONUS=150
|
||||
CREDITS_DAILY_FREE=5
|
||||
|
||||
# Monitoring
|
||||
GRAFANA_ADMIN_PASSWORD=your-secure-grafana-password
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_MAX=100
|
||||
26
.prettierrc
26
.prettierrc
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -43,21 +43,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
|
||||
### Archived Projects (`apps-archived/`)
|
||||
|
||||
These projects are temporarily archived and excluded from the workspace. To re-activate, move back to `apps/`.
|
||||
|
||||
| Project | Description |
|
||||
| ------------------ | -------------------------------- |
|
||||
| **bauntown** | Community website for developers |
|
||||
| **memoro** | Voice memo & AI analysis |
|
||||
| **news** | News aggregation |
|
||||
| **nutriphi** | Nutrition tracking |
|
||||
| **reader** | Reading app |
|
||||
| **uload** | URL shortener |
|
||||
| **wisekeep** | AI wisdom extraction from video |
|
||||
| **techbase** | Software comparison platform |
|
||||
| **inventory** | Inventory management |
|
||||
| **presi** | Presentation tool |
|
||||
| **storage** | Cloud storage |
|
||||
Currently empty. To archive a project, move it from `apps/` to `apps-archived/` (excluded from workspace).
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -1,15 +1,29 @@
|
|||
# Manacore Monorepo
|
||||
|
||||
Monorepo containing all Manacore projects with shared packages and unified tooling.
|
||||
Monorepo containing all Manacore projects — a self-hosted multi-app ecosystem with shared packages and unified tooling.
|
||||
|
||||
## Projects
|
||||
|
||||
| Project | Description | Tech Stack |
|
||||
| ------------------ | ------------------------------- | ------------------------------ |
|
||||
| **maerchenzauber** | AI-powered story generation app | NestJS, Expo, SvelteKit, Astro |
|
||||
| **manacore** | Multi-app ecosystem platform | Expo, SvelteKit, Astro |
|
||||
| **manadeck** | Card/deck management app | NestJS, Expo, SvelteKit |
|
||||
| **memoro** | Voice memo & AI analysis app | Expo, SvelteKit, Astro |
|
||||
| Project | Description | Apps |
|
||||
|---------|-------------|------|
|
||||
| **manacore** | Multi-app ecosystem platform | Expo mobile, SvelteKit web |
|
||||
| **chat** | AI chat application | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **todo** | Task management | NestJS backend, SvelteKit web, Astro landing |
|
||||
| **calendar** | Calendar & scheduling | NestJS backend, SvelteKit web, Astro landing |
|
||||
| **clock** | Pomodoro & time tracking | NestJS backend, SvelteKit web, Astro landing |
|
||||
| **contacts** | Contact management | NestJS backend, SvelteKit web |
|
||||
| **picture** | AI image generation | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **manadeck** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
|
||||
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **mukke** | Music player | NestJS backend, SvelteKit web |
|
||||
| **planta** | Plant care tracker | NestJS backend, SvelteKit web |
|
||||
| **storage** | Cloud storage | NestJS backend, SvelteKit web |
|
||||
| **questions** | Q&A with web search | SvelteKit web |
|
||||
| **skilltree** | Skill tree visualization | NestJS backend, SvelteKit web |
|
||||
| **nutriphi** | Nutrition tracking | NestJS backend, SvelteKit web |
|
||||
| **citycorners** | City guide | NestJS backend, SvelteKit web, Astro landing |
|
||||
| **presi** | Presentation tool | NestJS backend, SvelteKit web |
|
||||
| **photos** | Photo management | NestJS backend, SvelteKit web |
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
@ -17,107 +31,52 @@ Monorepo containing all Manacore projects with shared packages and unified tooli
|
|||
|
||||
- Node.js 20+
|
||||
- pnpm 9.15.0+
|
||||
- Docker (for PostgreSQL, Redis, MinIO)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install pnpm globally (if not installed)
|
||||
npm install -g pnpm
|
||||
|
||||
# Install all dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start all projects in dev mode
|
||||
pnpm run dev
|
||||
# Start infrastructure (PostgreSQL, Redis, MinIO)
|
||||
pnpm docker:up
|
||||
|
||||
# Start a specific project
|
||||
pnpm run maerchenzauber:dev
|
||||
pnpm run manacore:dev
|
||||
pnpm run manadeck:dev
|
||||
pnpm run memoro:dev
|
||||
# Start any app with auto DB setup
|
||||
pnpm dev:chat:full
|
||||
pnpm dev:todo:full
|
||||
pnpm dev:calendar:full
|
||||
pnpm dev:contacts:full
|
||||
|
||||
# Build all projects
|
||||
# Build & quality
|
||||
pnpm run build
|
||||
|
||||
# Run tests
|
||||
pnpm run test
|
||||
|
||||
# Type check
|
||||
pnpm run type-check
|
||||
|
||||
# Format code
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
## Shared Packages
|
||||
See [CLAUDE.md](./CLAUDE.md) for comprehensive development documentation.
|
||||
|
||||
Located in `packages/`:
|
||||
|
||||
| Package | Description |
|
||||
| --------------------------- | --------------------------------------- |
|
||||
| `@manacore/shared-types` | Common TypeScript types |
|
||||
| `@manacore/shared-supabase` | Unified Supabase client |
|
||||
| `@manacore/shared-utils` | Utility functions (date, string, async) |
|
||||
| `@manacore/shared-ui` | React Native UI components |
|
||||
|
||||
### Using Shared Packages
|
||||
|
||||
```typescript
|
||||
// In any project
|
||||
import { User, ApiResponse } from '@manacore/shared-types';
|
||||
import { createSupabaseClient } from '@manacore/shared-supabase';
|
||||
import { formatDate, truncate, retry } from '@manacore/shared-utils';
|
||||
```
|
||||
|
||||
## Repository Structure
|
||||
## Architecture
|
||||
|
||||
```
|
||||
manacore-monorepo/
|
||||
├── packages/ # Shared packages
|
||||
│ ├── shared-types/ # TypeScript types
|
||||
│ ├── shared-supabase/ # Supabase utilities
|
||||
│ ├── shared-utils/ # Common utilities
|
||||
│ └── shared-ui/ # React Native components
|
||||
├── maerchenzauber/ # Storyteller project
|
||||
├── manacore/ # Manacore apps project
|
||||
├── manadeck/ # ManaDeck project
|
||||
├── memoro/ # Memoro project
|
||||
├── turbo.json # Turborepo configuration
|
||||
├── pnpm-workspace.yaml # Workspace configuration
|
||||
└── package.json # Root package
|
||||
├── apps/ # Product applications
|
||||
├── services/ # Microservices (auth, search, LLM, bots)
|
||||
├── packages/ # Shared packages
|
||||
├── docker/ # Docker configuration
|
||||
└── scripts/ # Development & deployment scripts
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
- **Package Manager:** pnpm 9.15.0
|
||||
- **Build System:** Turborepo
|
||||
- **Formatting:** Prettier
|
||||
- **Node Version:** 20 (see .nvmrc)
|
||||
|
||||
## Adding Dependencies
|
||||
|
||||
```bash
|
||||
# Add to root (dev tools)
|
||||
pnpm add -D <package> -w
|
||||
|
||||
# Add to specific project
|
||||
pnpm add <package> --filter maerchenzauber
|
||||
|
||||
# Add to shared package
|
||||
pnpm add <package> --filter @manacore/shared-utils
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make changes
|
||||
3. Run `pnpm run format` and `pnpm run type-check`
|
||||
4. Commit with conventional commit messages
|
||||
5. Create pull request
|
||||
- **Formatting:** Prettier (tabs, single quotes, 100 char width)
|
||||
- **Hosting:** Mac Mini (self-hosted) via Docker + Cloudflare Tunnel
|
||||
- **Analytics:** Umami (stats.mana.how)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
# CLAUDE.md - Mukke
|
||||
|
||||
Offline-first iOS Music Player. Songs aus iCloud/lokalen Dateien importieren, lokal auf dem Gerät speichern, abspielen.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/mukke/
|
||||
├── package.json # Orchestrator (name: mukke)
|
||||
├── apps/
|
||||
│ └── mobile/ # @mukke/mobile (Expo SDK 54)
|
||||
│ ├── app/ # Expo Router screens
|
||||
│ │ ├── (tabs)/ # 4 Tab-Screens (Bibliothek, Playlists, Suche, Settings)
|
||||
│ │ ├── player.tsx # Full-Screen Player (modal)
|
||||
│ │ ├── queue.tsx # Queue Ansicht (modal)
|
||||
│ │ ├── album/[id] # Album Detail
|
||||
│ │ ├── artist/[id] # Artist Detail
|
||||
│ │ └── playlist/ # Playlist Detail + New
|
||||
│ ├── components/ # UI components
|
||||
│ ├── contexts/ # AudioContext (expo-audio)
|
||||
│ ├── stores/ # Zustand stores (player, library, playlist)
|
||||
│ ├── services/ # Business logic (DB, import, audio, library, playlist)
|
||||
│ └── utils/ # Theme system
|
||||
└── packages/
|
||||
└── mukke-types/ # @mukke/types (shared interfaces)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:mukke:mobile # Start Expo app
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Audio**: expo-audio (background via UIBackgroundModes: ["audio"])
|
||||
- **Import**: expo-document-picker (iCloud + lokale Dateien)
|
||||
- **Storage**: expo-file-system (documentDirectory)
|
||||
- **Metadata**: @missingcore/audio-metadata (ID3v2.3/v2.4)
|
||||
- **DB**: expo-sqlite (SQLite für Songs, Playlists)
|
||||
- **State**: Zustand
|
||||
- **Navigation**: Expo Router + NativeTabs
|
||||
- **Styling**: NativeWind / Tailwind
|
||||
|
||||
## Architecture
|
||||
|
||||
- **No backend** - pure offline, local-only app
|
||||
- **SQLite** for structured data (songs, playlists, playlist_songs)
|
||||
- **Albums/Artists/Genres** derived from songs table via queries (no separate tables)
|
||||
- **File storage**: documentDirectory/music/ + documentDirectory/artwork/
|
||||
- **Audio playback**: expo-audio with background mode
|
||||
- **MiniPlayer**: persistent above tab bar
|
||||
|
||||
## Import Flow
|
||||
|
||||
1. User taps Import → expo-document-picker opens (iCloud + local)
|
||||
2. Files copied to documentDirectory/music/{uuid}.ext
|
||||
3. Metadata extracted via @missingcore/audio-metadata
|
||||
4. Cover art saved to documentDirectory/artwork/{uuid}.jpg
|
||||
5. Song entry created in SQLite
|
||||
6
apps-archived/mukke/apps/mobile/.gitignore
vendored
6
apps-archived/mukke/apps/mobile/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Mukke",
|
||||
"slug": "mukke",
|
||||
"version": "1.0.0",
|
||||
"scheme": "mukke",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router", "expo-sqlite"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.mana.mukke",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"]
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.mana.mukke"
|
||||
},
|
||||
"owner": "memoro",
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "placeholder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { MiniPlayer } from '~/components/MiniPlayer';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Icon sf="music.note.list" />
|
||||
<Label>Bibliothek</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="playlists">
|
||||
<Icon sf="list.bullet" />
|
||||
<Label>Playlists</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="search">
|
||||
<Icon sf="magnifyingglass" />
|
||||
<Label>Suche</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<Icon sf="gearshape.fill" />
|
||||
<Label>Einstellungen</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
<MiniPlayer />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { AlbumGrid } from '~/components/AlbumGrid';
|
||||
import { ArtistList } from '~/components/ArtistList';
|
||||
import { GenreList } from '~/components/GenreList';
|
||||
import { ImportButton } from '~/components/ImportButton';
|
||||
import { SegmentedControl } from '~/components/SegmentedControl';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { SortMenu } from '~/components/SortMenu';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
import type { LibraryTab } from '~/types';
|
||||
|
||||
const SEGMENTS: { key: LibraryTab; label: string }[] = [
|
||||
{ key: 'songs', label: 'Songs' },
|
||||
{ key: 'albums', label: 'Alben' },
|
||||
{ key: 'artists', label: 'Künstler' },
|
||||
{ key: 'genres', label: 'Genres' },
|
||||
];
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const {
|
||||
songs,
|
||||
albums,
|
||||
artists,
|
||||
genres,
|
||||
activeTab,
|
||||
sortField,
|
||||
sortDirection,
|
||||
setActiveTab,
|
||||
setSortField,
|
||||
setSortDirection,
|
||||
loadAll,
|
||||
} = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Bibliothek',
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{activeTab === 'songs' && (
|
||||
<SortMenu
|
||||
currentField={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={(field, dir) => {
|
||||
setSortField(field);
|
||||
setSortDirection(dir);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ImportButton />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SegmentedControl segments={SEGMENTS} selected={activeTab} onSelect={setActiveTab} />
|
||||
|
||||
{activeTab === 'songs' && <SongList songs={songs} />}
|
||||
{activeTab === 'albums' && <AlbumGrid albums={albums} />}
|
||||
{activeTab === 'artists' && <ArtistList artists={artists} />}
|
||||
{activeTab === 'genres' && <GenreList genres={genres} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { FlatList, Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { ListItem } from '~/components/ListItem';
|
||||
import { usePlaylistStore } from '~/stores/playlistStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { playlists, loadPlaylists } = usePlaylistStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Playlists',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => router.push('/playlist/new')} style={{ padding: 8 }}>
|
||||
<Ionicons name="add" size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{playlists.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="list-outline"
|
||||
title="Keine Playlists"
|
||||
message="Erstelle eine Playlist über den + Button."
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={playlists}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={item.description || undefined}
|
||||
left={
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={24} color={colors.primary} />
|
||||
</View>
|
||||
}
|
||||
onPress={() => router.push(`/playlist/${item.id}`)}
|
||||
showChevron
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, TextInput } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { searchSongs } from '~/services/libraryService';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Song[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(async (text: string) => {
|
||||
setQuery(text);
|
||||
if (text.trim().length < 2) {
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
return;
|
||||
}
|
||||
setHasSearched(true);
|
||||
const songs = await searchSongs(text.trim());
|
||||
setResults(songs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen options={{ title: 'Suche' }} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
borderRadius: 10,
|
||||
margin: 16,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="search" size={18} color={colors.textTertiary} />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={handleSearch}
|
||||
placeholder="Songs, Künstler, Alben..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 8,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
}}
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!hasSearched ? (
|
||||
<EmptyState
|
||||
icon="search-outline"
|
||||
title="Suche"
|
||||
message="Suche nach Songs, Künstlern oder Alben."
|
||||
/>
|
||||
) : results.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="search-outline"
|
||||
title="Keine Ergebnisse"
|
||||
message={`Keine Treffer für "${query}".`}
|
||||
/>
|
||||
) : (
|
||||
<SongList songs={results} emptyTitle="Keine Ergebnisse" />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Switch, ScrollView, Alert, Pressable } from 'react-native';
|
||||
|
||||
import { useTheme, type ThemeVariant } from '~/utils/themeContext';
|
||||
import { getStorageInfo, formatFileSize } from '~/services/fileService';
|
||||
import { getSongCount } from '~/services/libraryService';
|
||||
import { pickAndImportFiles } from '~/services/importService';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { colors, isDarkMode, toggleTheme, themeVariant, setThemeVariant } = useTheme();
|
||||
const loadAll = useLibraryStore((s) => s.loadAll);
|
||||
const [storageInfo, setStorageInfo] = useState({ musicSize: 0, artworkSize: 0, totalFiles: 0 });
|
||||
const [songCount, setSongCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadInfo();
|
||||
}, []);
|
||||
|
||||
const loadInfo = async () => {
|
||||
const [storage, count] = await Promise.all([getStorageInfo(), getSongCount()]);
|
||||
setStorageInfo(storage);
|
||||
setSongCount(count);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const songs = await pickAndImportFiles();
|
||||
if (songs.length > 0) {
|
||||
await loadAll();
|
||||
await loadInfo();
|
||||
Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
|
||||
}
|
||||
};
|
||||
|
||||
const variants: { key: ThemeVariant; label: string; color: string }[] = [
|
||||
{ key: 'classic', label: 'Orange', color: '#FF6B35' },
|
||||
{ key: 'ocean', label: 'Blau', color: '#2196F3' },
|
||||
{ key: 'sunset', label: 'Rot', color: '#FF6B6B' },
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<Stack.Screen options={{ title: 'Einstellungen' }} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Darstellung
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, color: colors.text }}>Dark Mode</Text>
|
||||
<Switch value={isDarkMode} onValueChange={toggleTheme} />
|
||||
</View>
|
||||
<View style={{ height: 0.5, backgroundColor: colors.border, marginLeft: 16 }} />
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ fontSize: 16, color: colors.text, marginBottom: 12 }}>Akzentfarbe</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
{variants.map((v) => (
|
||||
<Pressable
|
||||
key={v.key}
|
||||
onPress={() => setThemeVariant(v.key)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: v.color,
|
||||
borderWidth: themeVariant === v.key ? 3 : 0,
|
||||
borderColor: colors.text,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Import */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Musik
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleImport}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', padding: 16 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-circle-outline"
|
||||
size={22}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{ fontSize: 16, color: colors.primary }}>Songs importieren</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Storage */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Speicher
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Songs</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>{songCount}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Musik</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
|
||||
{formatFileSize(storageInfo.musicSize)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Cover Art</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
|
||||
{formatFileSize(storageInfo.artworkSize)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Info
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Version</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>1.0.0</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Mukke</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>Offline Music Player</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className="items-center flex-1 justify-center p-5">
|
||||
<Text className="text-xl font-bold">Diese Seite existiert nicht.</Text>
|
||||
<Link href="/" className="mt-4 pt-4">
|
||||
<Text className="text-base text-[#2e78b7]">Zur Bibliothek</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import '../global.css';
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { AudioProvider } from '~/contexts/AudioContext';
|
||||
import { ThemeProvider } from '~/utils/themeContext';
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
{({ isDarkMode }) => (
|
||||
<ThemeWrapper>
|
||||
<AudioProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : 'transparent',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerTitleStyle: {
|
||||
color: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="player"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="queue"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
title: 'Warteschlange',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="album/[id]"
|
||||
options={{ title: 'Album', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="artist/[id]"
|
||||
options={{ title: 'Künstler', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="playlist/[id]"
|
||||
options={{ title: 'Playlist', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="playlist/new"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
title: 'Neue Playlist',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</AudioProvider>
|
||||
</ThemeWrapper>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { getSongsByAlbum } from '~/services/libraryService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const albumName = decodeURIComponent(id || '');
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumName) {
|
||||
getSongsByAlbum(albumName).then(setSongs);
|
||||
}
|
||||
}, [albumName]);
|
||||
|
||||
const coverArt = songs.find((s) => s.coverArtPath)?.coverArtPath || null;
|
||||
const artist = songs[0]?.albumArtist || songs[0]?.artist || 'Unbekannt';
|
||||
const year = songs[0]?.year;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Album Header */}
|
||||
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
|
||||
<Artwork uri={coverArt} size={180} />
|
||||
<Text
|
||||
style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{albumName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{artist}
|
||||
{year ? ` · ${year}` : ''} · {songs.length} Songs
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Keine Songs"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { getSongsByArtist } from '~/services/libraryService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function ArtistDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const artistName = decodeURIComponent(id || '');
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (artistName) {
|
||||
getSongsByArtist(artistName).then(setSongs);
|
||||
}
|
||||
}, [artistName]);
|
||||
|
||||
const albumCount = new Set(songs.map((s) => s.album).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Artist Header */}
|
||||
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
|
||||
<Artwork uri={null} size={120} rounded />
|
||||
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text, marginTop: 12 }}>
|
||||
{artistName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{songs.length} Songs · {albumCount} Alben
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Keine Songs"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { ProgressBar } from '~/components/ProgressBar';
|
||||
import { TransportControls } from '~/components/TransportControls';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlayerScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { seekTo } = useAudio();
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const position = usePlayerStore((s) => s.position);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const toggleFavorite = useLibraryStore((s) => s.toggleFavorite);
|
||||
|
||||
if (!currentSong) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: colors.textSecondary }}>Kein Song wird abgespielt</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} style={{ padding: 4 }}>
|
||||
<Ionicons name="chevron-down" size={28} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary, fontWeight: '600' }}>
|
||||
WIRD ABGESPIELT
|
||||
</Text>
|
||||
<Pressable onPress={() => router.push('/queue')} style={{ padding: 4 }}>
|
||||
<Ionicons name="list" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Artwork */}
|
||||
<View
|
||||
style={{ alignItems: 'center', paddingHorizontal: 40, flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
<Artwork uri={currentSong.coverArtPath} size={300} />
|
||||
</View>
|
||||
|
||||
{/* Song Info */}
|
||||
<View style={{ paddingHorizontal: 24, marginBottom: 16 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text }} numberOfLines={1}>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 16, color: colors.textSecondary, marginTop: 4 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => toggleFavorite(currentSong.id)} style={{ padding: 8 }}>
|
||||
<Ionicons
|
||||
name={currentSong.favorite ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={currentSong.favorite ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress */}
|
||||
<ProgressBar position={position} duration={duration} onSeek={seekTo} />
|
||||
|
||||
{/* Transport */}
|
||||
<View style={{ paddingVertical: 24 }}>
|
||||
<TransportControls size="large" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Pressable, Alert } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { SongPicker } from '~/components/SongPicker';
|
||||
import {
|
||||
getPlaylistById,
|
||||
getPlaylistSongs,
|
||||
addSongToPlaylist,
|
||||
removeSongFromPlaylist,
|
||||
} from '~/services/playlistService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Playlist, Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlaylistDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [playlist, setPlaylist] = useState<Playlist | null>(null);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!id) return;
|
||||
const [p, s] = await Promise.all([getPlaylistById(id), getPlaylistSongs(id)]);
|
||||
setPlaylist(p);
|
||||
setSongs(s);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const handleAddSongs = async (selected: Song[]) => {
|
||||
if (!id) return;
|
||||
for (const song of selected) {
|
||||
await addSongToPlaylist(id, song.id);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleLongPress = (song: Song) => {
|
||||
Alert.alert('Song entfernen', `"${song.title}" aus der Playlist entfernen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Entfernen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (id) {
|
||||
await removeSongFromPlaylist(id, song.id);
|
||||
await loadData();
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: playlist?.name || 'Playlist',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => setShowPicker(true)} style={{ padding: 8 }}>
|
||||
<Ionicons name="add" size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{playlist && (
|
||||
<View style={{ padding: 16, alignItems: 'center' }}>
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}>
|
||||
{playlist.name}
|
||||
</Text>
|
||||
{playlist.description && (
|
||||
<Text style={{ fontSize: 14, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{playlist.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ fontSize: 13, color: colors.textTertiary, marginTop: 4 }}>
|
||||
{songs.length} Songs
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Playlist ist leer"
|
||||
emptyMessage="Füge Songs über den + Button hinzu."
|
||||
/>
|
||||
|
||||
<SongPicker
|
||||
visible={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddSongs}
|
||||
excludeIds={songs.map((s) => s.id)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable } from 'react-native';
|
||||
|
||||
import { usePlaylistStore } from '~/stores/playlistStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function NewPlaylistScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
const playlist = await createPlaylist(name.trim(), description.trim() || undefined);
|
||||
router.dismiss();
|
||||
router.push(`/playlist/${playlist.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background, padding: 20 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Playlist Name"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>
|
||||
Beschreibung (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Beschreibung..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
marginBottom: 32,
|
||||
minHeight: 80,
|
||||
}}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={handleCreate}
|
||||
disabled={!name.trim()}
|
||||
style={{
|
||||
backgroundColor: name.trim() ? colors.primary : colors.backgroundTertiary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: name.trim() ? '#FFFFFF' : colors.textTertiary,
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Text, FlatList } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { ListItem } from '~/components/ListItem';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function QueueScreen() {
|
||||
const { colors } = useTheme();
|
||||
const queue = usePlayerStore((s) => s.getQueue());
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
const currentIndex = queue.findIndex((s) => s.id === currentSong?.id);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{currentSong && (
|
||||
<View style={{ padding: 16, borderBottomWidth: 0.5, borderBottomColor: colors.border }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
AKTUELLER SONG
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Artwork uri={currentSong.coverArtPath} size={48} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
ALS NÄCHSTES
|
||||
</Text>
|
||||
|
||||
<FlatList
|
||||
data={queue.slice(currentIndex + 1)}
|
||||
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
renderItem={({ item, index }) => (
|
||||
<ListItem
|
||||
title={item.title}
|
||||
subtitle={item.artist || 'Unbekannt'}
|
||||
trailing={formatDuration(item.duration)}
|
||||
left={<Artwork uri={item.coverArtPath} size={40} />}
|
||||
onPress={() => playSong(item, queue, currentIndex + 1 + index)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -1,10 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FlatList, Image, Pressable, Text, View, useWindowDimensions } from 'react-native';
|
||||
|
||||
import type { Album } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
interface AlbumGridProps {
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export function AlbumGrid({ albums }: AlbumGridProps) {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemSize = (width - 48) / 2;
|
||||
|
||||
if (albums.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="disc-outline"
|
||||
title="Keine Alben"
|
||||
message="Importierte Songs werden nach Alben gruppiert."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={albums}
|
||||
keyExtractor={(item) => item.name}
|
||||
numColumns={2}
|
||||
contentContainerStyle={{ padding: 12, paddingBottom: 100 }}
|
||||
columnWrapperStyle={{ gap: 12 }}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => router.push(`/album/${encodeURIComponent(item.name)}`)}
|
||||
style={{ width: itemSize }}
|
||||
>
|
||||
{item.coverArtPath ? (
|
||||
<Image
|
||||
source={{ uri: item.coverArtPath }}
|
||||
style={{ width: itemSize, height: itemSize, borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="disc-outline" size={48} color={colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={{ fontSize: 14, fontWeight: '600', color: colors.text, marginTop: 6 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{item.artist || 'Unbekannt'} · {item.songCount} Songs
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import type { Artist } from '~/types';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface ArtistListProps {
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export function ArtistList({ artists }: ArtistListProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (artists.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="person-outline"
|
||||
title="Keine Künstler"
|
||||
message="Importierte Songs werden nach Künstlern gruppiert."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={artists}
|
||||
keyExtractor={(item) => item.name}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={`${item.songCount} Songs · ${item.albumCount} Alben`}
|
||||
left={<Artwork uri={null} size={44} rounded />}
|
||||
onPress={() => router.push(`/artist/${encodeURIComponent(item.name)}`)}
|
||||
showChevron
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ArtworkProps {
|
||||
uri: string | null | undefined;
|
||||
size?: number;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export function Artwork({ uri, size = 48, rounded = false }: ArtworkProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
if (uri) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: rounded ? size / 2 : 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: rounded ? size / 2 : 8,
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-note" size={size * 0.4} color={colors.textTertiary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { Pressable, Text, ActivityIndicator } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ title, onPress, variant = 'primary', loading, disabled }: ButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const bgColor =
|
||||
variant === 'primary'
|
||||
? colors.primary
|
||||
: variant === 'secondary'
|
||||
? colors.backgroundTertiary
|
||||
: 'transparent';
|
||||
|
||||
const textColor = variant === 'primary' ? '#FFFFFF' : colors.text;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={textColor} size="small" />
|
||||
) : (
|
||||
<Text style={{ color: textColor, fontWeight: '600', fontSize: 16 }}>{title}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = 'musical-notes-outline', title, message }: EmptyStateProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<Ionicons name={icon} size={64} color={colors.textTertiary} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{message && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlatList, View } from 'react-native';
|
||||
|
||||
import type { Genre } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { getSongsByGenre } from '~/services/libraryService';
|
||||
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface GenreListProps {
|
||||
genres: Genre[];
|
||||
}
|
||||
|
||||
export function GenreList({ genres }: GenreListProps) {
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
if (genres.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="albums-outline"
|
||||
title="Keine Genres"
|
||||
message="Genre-Tags werden beim Import aus den Metadaten gelesen."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePress = async (genre: Genre) => {
|
||||
const songs = await getSongsByGenre(genre.name);
|
||||
if (songs.length > 0) {
|
||||
playSong(songs[0], songs, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={genres}
|
||||
keyExtractor={(item) => item.name}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={`${item.songCount} Songs`}
|
||||
left={
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={22} color={colors.primary} />
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Alert } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { pickAndImportFiles } from '~/services/importService';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
|
||||
export function ImportButton() {
|
||||
const { colors } = useTheme();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const loadAll = useLibraryStore((s) => s.loadAll);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (importing) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const songs = await pickAndImportFiles();
|
||||
if (songs.length > 0) {
|
||||
await loadAll();
|
||||
Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable onPress={handleImport} disabled={importing} style={{ padding: 8 }}>
|
||||
<Ionicons name={importing ? 'hourglass' : 'add'} size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ListItemProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
trailing?: string;
|
||||
left?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
showChevron?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
left,
|
||||
onPress,
|
||||
onLongPress,
|
||||
showChevron,
|
||||
}: ListItemProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: pressed ? colors.backgroundTertiary : 'transparent',
|
||||
})}
|
||||
>
|
||||
{left && <View style={{ marginRight: 12 }}>{left}</View>}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 16, color: colors.text }} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{ fontSize: 13, color: colors.textSecondary, marginTop: 2 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{trailing && (
|
||||
<Text style={{ fontSize: 13, color: colors.textTertiary, marginLeft: 8 }}>{trailing}</Text>
|
||||
)}
|
||||
{showChevron && (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={18}
|
||||
color={colors.textTertiary}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { play, pause, playNext } = useAudio();
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const position = usePlayerStore((s) => s.position);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
|
||||
if (!currentSong) return null;
|
||||
|
||||
const progress = duration > 0 ? position / duration : 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push('/player')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 49,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.border,
|
||||
}}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<View style={{ height: 2, backgroundColor: colors.backgroundTertiary }}>
|
||||
<View
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: colors.primary,
|
||||
width: `${progress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Artwork uri={currentSong.coverArtPath} size={40} />
|
||||
|
||||
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: '600', color: colors.text }} numberOfLines={1}>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={isPlaying ? pause : play} style={{ padding: 8 }}>
|
||||
<Ionicons name={isPlaying ? 'pause' : 'play'} size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={playNext} style={{ padding: 8 }}>
|
||||
<Ionicons name="play-skip-forward" size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
|
||||
interface ProgressBarProps {
|
||||
position: number;
|
||||
duration: number;
|
||||
onSeek: (position: number) => void;
|
||||
}
|
||||
|
||||
export function ProgressBar({ position, duration, onSeek }: ProgressBarProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ width: '100%', paddingHorizontal: 20 }}>
|
||||
<Slider
|
||||
value={duration > 0 ? position / duration : 0}
|
||||
onSlidingComplete={(value) => onSeek(value * duration)}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
minimumTrackTintColor={colors.primary}
|
||||
maximumTrackTintColor={colors.backgroundTertiary}
|
||||
thumbTintColor={colors.primary}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
{formatDuration(position)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
-{formatDuration(Math.max(0, duration - position))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
segments: { key: T; label: string }[];
|
||||
selected: T;
|
||||
onSelect: (key: T) => void;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
segments,
|
||||
selected,
|
||||
onSelect,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
}}
|
||||
>
|
||||
{segments.map((seg) => {
|
||||
const isActive = seg.key === selected;
|
||||
return (
|
||||
<Pressable
|
||||
key={seg.key}
|
||||
onPress={() => onSelect(seg.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: isActive ? colors.card : 'transparent',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
color: isActive ? colors.text : colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{seg.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { FlatList } from 'react-native';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
onSongPress?: (song: Song, index: number) => void;
|
||||
emptyTitle?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function SongList({
|
||||
songs,
|
||||
onSongPress,
|
||||
emptyTitle = 'Keine Songs',
|
||||
emptyMessage = 'Importiere Songs über den + Button.',
|
||||
}: SongListProps) {
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
const handlePress = (song: Song, index: number) => {
|
||||
if (onSongPress) {
|
||||
onSongPress(song, index);
|
||||
} else {
|
||||
playSong(song, songs, index);
|
||||
}
|
||||
};
|
||||
|
||||
if (songs.length === 0) {
|
||||
return <EmptyState title={emptyTitle} message={emptyMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={songs}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<ListItem
|
||||
title={item.title}
|
||||
subtitle={[item.artist, item.album].filter(Boolean).join(' · ')}
|
||||
trailing={formatDuration(item.duration)}
|
||||
left={<Artwork uri={item.coverArtPath} size={44} />}
|
||||
onPress={() => handlePress(item, index)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FlatList, Pressable, View, Text, Modal } from 'react-native';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { getAllSongs } from '~/services/libraryService';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
|
||||
interface SongPickerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (songs: Song[]) => void;
|
||||
excludeIds?: string[];
|
||||
}
|
||||
|
||||
export function SongPicker({ visible, onClose, onSelect, excludeIds = [] }: SongPickerProps) {
|
||||
const { colors } = useTheme();
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
getAllSongs().then((all) => {
|
||||
setSongs(all.filter((s) => !excludeIds.includes(s.id)));
|
||||
});
|
||||
setSelected(new Set());
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
const selectedSongs = songs.filter((s) => selected.has(s.id));
|
||||
onSelect(selectedSongs);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text style={{ fontSize: 16, color: colors.primary }}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 17, fontWeight: '600', color: colors.text }}>
|
||||
Songs auswählen
|
||||
</Text>
|
||||
<Pressable onPress={handleDone}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}>
|
||||
Fertig ({selected.size})
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={songs}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = selected.has(item.id);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => toggleSelection(item.id)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isSelected ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={24}
|
||||
color={isSelected ? colors.primary : colors.textTertiary}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Artwork uri={item.coverArtPath} size={40} />
|
||||
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{item.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, View, Text, Modal } from 'react-native';
|
||||
|
||||
import type { SortField, SortDirection } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
const SORT_OPTIONS: { field: SortField; label: string }[] = [
|
||||
{ field: 'title', label: 'Titel' },
|
||||
{ field: 'artist', label: 'Künstler' },
|
||||
{ field: 'album', label: 'Album' },
|
||||
{ field: 'addedAt', label: 'Hinzugefügt' },
|
||||
{ field: 'playCount', label: 'Wiedergaben' },
|
||||
];
|
||||
|
||||
interface SortMenuProps {
|
||||
currentField: SortField;
|
||||
currentDirection: SortDirection;
|
||||
onSort: (field: SortField, direction: SortDirection) => void;
|
||||
}
|
||||
|
||||
export function SortMenu({ currentField, currentDirection, onSort }: SortMenuProps) {
|
||||
const { colors } = useTheme();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable onPress={() => setVisible(true)} style={{ padding: 8 }}>
|
||||
<Ionicons name="swap-vertical" size={22} color={colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<Pressable
|
||||
onPress={() => setVisible(false)}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text, marginBottom: 16 }}>
|
||||
Sortieren
|
||||
</Text>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const isActive = opt.field === currentField;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.field}
|
||||
onPress={() => {
|
||||
if (isActive) {
|
||||
onSort(opt.field, currentDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
onSort(opt.field, 'asc');
|
||||
}
|
||||
setVisible(false);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: isActive ? colors.primary : colors.text,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<Ionicons
|
||||
name={currentDirection === 'asc' ? 'arrow-up' : 'arrow-down'}
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
type ThemeWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ThemeWrapper: React.FC<ThemeWrapperProps> = ({ children, className = '' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`${isDarkMode ? 'dark bg-background-dark' : 'bg-background-light'} flex-1 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Pressable } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { RepeatMode, ShuffleMode } from '~/types';
|
||||
|
||||
interface TransportControlsProps {
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
function getRepeatIcon(mode: RepeatMode): keyof typeof Ionicons.glyphMap {
|
||||
if (mode === 'one') return 'repeat';
|
||||
return 'repeat';
|
||||
}
|
||||
|
||||
export function TransportControls({ size = 'large' }: TransportControlsProps) {
|
||||
const { colors } = useTheme();
|
||||
const { play, pause, playNext, playPrevious } = useAudio();
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const repeatMode = usePlayerStore((s) => s.repeatMode);
|
||||
const shuffleMode = usePlayerStore((s) => s.shuffleMode);
|
||||
const toggleRepeat = usePlayerStore((s) => s.toggleRepeat);
|
||||
const toggleShuffle = usePlayerStore((s) => s.toggleShuffle);
|
||||
|
||||
const iconSize = size === 'large' ? 36 : 24;
|
||||
const playSize = size === 'large' ? 56 : 32;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: size === 'large' ? 32 : 20,
|
||||
}}
|
||||
>
|
||||
{size === 'large' && (
|
||||
<Pressable onPress={toggleShuffle}>
|
||||
<Ionicons
|
||||
name="shuffle"
|
||||
size={24}
|
||||
color={shuffleMode === 'on' ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable onPress={playPrevious}>
|
||||
<Ionicons name="play-skip-back" size={iconSize} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={isPlaying ? pause : play}
|
||||
style={{
|
||||
width: playSize,
|
||||
height: playSize,
|
||||
borderRadius: playSize / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
size={playSize * 0.5}
|
||||
color="#FFFFFF"
|
||||
style={{ marginLeft: isPlaying ? 0 : 2 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={playNext}>
|
||||
<Ionicons name="play-skip-forward" size={iconSize} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
{size === 'large' && (
|
||||
<Pressable onPress={toggleRepeat}>
|
||||
<Ionicons
|
||||
name={getRepeatIcon(repeatMode)}
|
||||
size={24}
|
||||
color={repeatMode !== 'off' ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
{repeatMode === 'one' && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -6,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="remove" size={8} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { updatePlayStats, updateSongDuration } from '~/services/libraryService';
|
||||
|
||||
interface AudioContextType {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seekTo: (position: number) => void;
|
||||
playNext: () => void;
|
||||
playPrevious: () => void;
|
||||
}
|
||||
|
||||
const AudioCtx = createContext<AudioContextType>({
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seekTo: () => {},
|
||||
playNext: () => {},
|
||||
playPrevious: () => {},
|
||||
});
|
||||
|
||||
export const useAudio = () => useContext(AudioCtx);
|
||||
|
||||
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const player = useAudioPlayer(null);
|
||||
const status = useAudioPlayerStatus(player);
|
||||
const { currentSong, isPlaying, setPlaying, setPosition, setDuration, nextSong, previousSong } =
|
||||
usePlayerStore();
|
||||
const hasCountedPlay = useRef(false);
|
||||
const lastSongId = useRef<string | null>(null);
|
||||
|
||||
// Configure audio mode for background playback
|
||||
useEffect(() => {
|
||||
setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load song when currentSong changes
|
||||
useEffect(() => {
|
||||
if (!currentSong) return;
|
||||
if (lastSongId.current === currentSong.id) return;
|
||||
lastSongId.current = currentSong.id;
|
||||
hasCountedPlay.current = false;
|
||||
|
||||
player.replace({ uri: currentSong.filePath });
|
||||
|
||||
// Set lock screen metadata
|
||||
player.setActiveForLockScreen(true, {
|
||||
title: currentSong.title,
|
||||
artist: currentSong.artist || undefined,
|
||||
albumTitle: currentSong.album || undefined,
|
||||
artworkSource: currentSong.coverArtPath ? { uri: currentSong.coverArtPath } : undefined,
|
||||
});
|
||||
|
||||
player.play();
|
||||
}, [currentSong?.id]);
|
||||
|
||||
// Sync play/pause state
|
||||
useEffect(() => {
|
||||
if (!currentSong) return;
|
||||
if (isPlaying && !status.playing) {
|
||||
player.play();
|
||||
} else if (!isPlaying && status.playing) {
|
||||
player.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Update position and duration from player status
|
||||
useEffect(() => {
|
||||
if (status.currentTime !== undefined) {
|
||||
setPosition(status.currentTime);
|
||||
}
|
||||
if (status.duration && status.duration > 0) {
|
||||
setDuration(status.duration);
|
||||
// Save duration to DB if not yet stored
|
||||
if (currentSong && !currentSong.duration) {
|
||||
updateSongDuration(currentSong.id, status.duration);
|
||||
}
|
||||
}
|
||||
}, [status.currentTime, status.duration]);
|
||||
|
||||
// Count play after 10 seconds
|
||||
useEffect(() => {
|
||||
if (currentSong && status.currentTime > 10 && !hasCountedPlay.current) {
|
||||
hasCountedPlay.current = true;
|
||||
updatePlayStats(currentSong.id);
|
||||
}
|
||||
}, [status.currentTime]);
|
||||
|
||||
// Auto-advance when track ends
|
||||
useEffect(() => {
|
||||
if (status.didJustFinish) {
|
||||
const next = nextSong();
|
||||
if (!next) {
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [status.didJustFinish]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
player.play();
|
||||
setPlaying(true);
|
||||
}, [player]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
player.pause();
|
||||
setPlaying(false);
|
||||
}, [player]);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(position: number) => {
|
||||
player.seekTo(position);
|
||||
setPosition(position);
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const playNext = useCallback(() => {
|
||||
const song = nextSong();
|
||||
if (!song) setPlaying(false);
|
||||
}, []);
|
||||
|
||||
const playPrevious = useCallback(() => {
|
||||
const song = previousSong();
|
||||
if (song && song.id === currentSong?.id) {
|
||||
// Restart current song
|
||||
player.seekTo(0);
|
||||
setPosition(0);
|
||||
}
|
||||
}, [currentSong?.id, player]);
|
||||
|
||||
return (
|
||||
<AudioCtx.Provider value={{ play, pause, seekTo, playNext, playPrevious }}>
|
||||
{children}
|
||||
</AudioCtx.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.17.4",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "22.15.0",
|
||||
"pnpm": "10.18.1",
|
||||
"env": {
|
||||
"PNPM_WORKSPACE_ROOT": "../..",
|
||||
"EAS_BUILD": "true"
|
||||
},
|
||||
"cache": {
|
||||
"disabled": false,
|
||||
"key": "v1",
|
||||
"cacheDefaultPaths": true,
|
||||
"customPaths": ["node_modules", "../../node_modules"]
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"extends": "base",
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
const path = require('path');
|
||||
|
||||
// Get the project and workspace root directories
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, '../../../..');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// Watch all files within the monorepo (needed for workspace packages like @mukke/types)
|
||||
config.watchFolders = [path.resolve(projectRoot, '../../packages'), monorepoRoot + '/node_modules'];
|
||||
|
||||
// Let Metro know where to resolve packages and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(monorepoRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// Support .cjs and .mjs extensions
|
||||
config.resolver.sourceExts = [...config.resolver.sourceExts, 'cjs', 'mjs'];
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
{
|
||||
"name": "@mukke/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@missingcore/audio-metadata": "^1.3.0",
|
||||
"@mukke/types": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "5.1.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "~55.0.0",
|
||||
"expo-audio": "~55.0.0",
|
||||
"expo-constants": "~55.0.0",
|
||||
"expo-dev-client": "~55.0.0",
|
||||
"expo-dev-launcher": "~55.0.0",
|
||||
"expo-document-picker": "~55.0.0",
|
||||
"expo-file-system": "~55.0.0",
|
||||
"expo-router": "~55.0.0",
|
||||
"expo-sqlite": "~55.0.0",
|
||||
"expo-status-bar": "~55.0.0",
|
||||
"expo-system-ui": "~55.0.0",
|
||||
"nativewind": "^4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "~4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "~0.7.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-universe": "^14.0.0",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "universe/native",
|
||||
"root": true
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { setAudioModeAsync } from 'expo-audio';
|
||||
|
||||
export { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
|
||||
export async function configureAudioMode(): Promise<void> {
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null | undefined): string {
|
||||
if (!seconds || seconds <= 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (db) return db;
|
||||
db = await SQLite.openDatabaseAsync('mukke.db');
|
||||
await initializeDatabase(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
async function initializeDatabase(database: SQLite.SQLiteDatabase): Promise<void> {
|
||||
await database.execAsync(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
albumArtist TEXT,
|
||||
genre TEXT,
|
||||
trackNumber INTEGER,
|
||||
discNumber INTEGER,
|
||||
year INTEGER,
|
||||
duration REAL,
|
||||
filePath TEXT NOT NULL,
|
||||
fileSize INTEGER,
|
||||
coverArtPath TEXT,
|
||||
addedAt TEXT NOT NULL,
|
||||
lastPlayedAt TEXT,
|
||||
playCount INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
coverArtPath TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
playlistId TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
songId TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
sortOrder INTEGER NOT NULL,
|
||||
addedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_favorite ON songs(favorite);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlistId);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (db) {
|
||||
await db.closeAsync();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MUSIC_DIR = `${FileSystem.documentDirectory}music/`;
|
||||
const ARTWORK_DIR = `${FileSystem.documentDirectory}artwork/`;
|
||||
|
||||
export async function ensureDirectories(): Promise<void> {
|
||||
const musicInfo = await FileSystem.getInfoAsync(MUSIC_DIR);
|
||||
if (!musicInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(MUSIC_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
const artworkInfo = await FileSystem.getInfoAsync(ARTWORK_DIR);
|
||||
if (!artworkInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(ARTWORK_DIR, { intermediates: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(uri: string): string {
|
||||
const parts = uri.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'mp3';
|
||||
}
|
||||
|
||||
export async function copyToMusicDirectory(
|
||||
sourceUri: string
|
||||
): Promise<{ path: string; id: string }> {
|
||||
await ensureDirectories();
|
||||
const id = uuidv4();
|
||||
const ext = getFileExtension(sourceUri);
|
||||
const destPath = `${MUSIC_DIR}${id}.${ext}`;
|
||||
await FileSystem.copyAsync({ from: sourceUri, to: destPath });
|
||||
return { path: destPath, id };
|
||||
}
|
||||
|
||||
export async function saveArtwork(data: Uint8Array, songId: string): Promise<string> {
|
||||
await ensureDirectories();
|
||||
const artworkPath = `${ARTWORK_DIR}${songId}.jpg`;
|
||||
const base64 = uint8ArrayToBase64(data);
|
||||
await FileSystem.writeAsStringAsync(artworkPath, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStorageInfo(): Promise<{
|
||||
musicSize: number;
|
||||
artworkSize: number;
|
||||
totalFiles: number;
|
||||
}> {
|
||||
let musicSize = 0;
|
||||
let artworkSize = 0;
|
||||
let totalFiles = 0;
|
||||
|
||||
try {
|
||||
const musicFiles = await FileSystem.readDirectoryAsync(MUSIC_DIR);
|
||||
for (const file of musicFiles) {
|
||||
const info = await FileSystem.getInfoAsync(`${MUSIC_DIR}${file}`);
|
||||
if (info.exists && !info.isDirectory && 'size' in info) {
|
||||
musicSize += info.size ?? 0;
|
||||
totalFiles++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
try {
|
||||
const artworkFiles = await FileSystem.readDirectoryAsync(ARTWORK_DIR);
|
||||
for (const file of artworkFiles) {
|
||||
const info = await FileSystem.getInfoAsync(`${ARTWORK_DIR}${file}`);
|
||||
if (info.exists && !info.isDirectory && 'size' in info) {
|
||||
artworkSize += info.size ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
return { musicSize, artworkSize, totalFiles };
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { getAudioMetadata } from '@missingcore/audio-metadata';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
|
||||
import { copyToMusicDirectory, saveArtwork } from './fileService';
|
||||
import { insertSong } from './libraryService';
|
||||
|
||||
const SUPPORTED_TYPES = ['audio/*'];
|
||||
|
||||
export async function pickAndImportFiles(): Promise<Song[]> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: SUPPORTED_TYPES,
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const importedSongs: Song[] = [];
|
||||
|
||||
for (const asset of result.assets) {
|
||||
try {
|
||||
const song = await importSingleFile(asset);
|
||||
if (song) {
|
||||
importedSongs.push(song);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to import ${asset.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return importedSongs;
|
||||
}
|
||||
|
||||
async function importSingleFile(asset: DocumentPicker.DocumentPickerAsset): Promise<Song | null> {
|
||||
// Copy to permanent storage
|
||||
const { path: filePath, id } = await copyToMusicDirectory(asset.uri);
|
||||
|
||||
// Get file size
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
const fileSize = fileInfo.exists && 'size' in fileInfo ? (fileInfo.size ?? null) : null;
|
||||
|
||||
// Extract metadata
|
||||
let metadata: Awaited<ReturnType<typeof getAudioMetadata>> | null = null;
|
||||
try {
|
||||
metadata = await getAudioMetadata(filePath, [
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'albumArtist',
|
||||
'genre',
|
||||
'trackNumber',
|
||||
'year',
|
||||
'picture',
|
||||
]);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read metadata:', error);
|
||||
}
|
||||
|
||||
// Save cover art if available
|
||||
let coverArtPath: string | null = null;
|
||||
if (metadata?.metadata?.picture) {
|
||||
try {
|
||||
const pictureData = metadata.metadata.picture;
|
||||
if (pictureData && typeof pictureData === 'object' && 'data' in pictureData) {
|
||||
coverArtPath = await saveArtwork(pictureData.data as Uint8Array, id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save cover art:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Build title from metadata or filename
|
||||
const title = (metadata?.metadata?.title as string) || asset.name.replace(/\.[^.]+$/, '');
|
||||
|
||||
const song: Song = {
|
||||
id,
|
||||
title,
|
||||
artist: (metadata?.metadata?.artist as string) || null,
|
||||
album: (metadata?.metadata?.album as string) || null,
|
||||
albumArtist: (metadata?.metadata?.albumArtist as string) || null,
|
||||
genre: (metadata?.metadata?.genre as string) || null,
|
||||
trackNumber: metadata?.metadata?.trackNumber
|
||||
? parseInt(String(metadata.metadata.trackNumber), 10) || null
|
||||
: null,
|
||||
discNumber: null,
|
||||
year: metadata?.metadata?.year ? parseInt(String(metadata.metadata.year), 10) || null : null,
|
||||
duration: null,
|
||||
filePath,
|
||||
fileSize,
|
||||
coverArtPath,
|
||||
addedAt: new Date().toISOString(),
|
||||
lastPlayedAt: null,
|
||||
playCount: 0,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
await insertSong(song);
|
||||
return song;
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import type { Album, Artist, Genre, Song } from '~/types';
|
||||
|
||||
import { getDatabase } from './database';
|
||||
import { deleteFile } from './fileService';
|
||||
|
||||
export async function insertSong(song: Song): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
`INSERT INTO songs (id, title, artist, album, albumArtist, genre, trackNumber, discNumber, year, duration, filePath, fileSize, coverArtPath, addedAt, lastPlayedAt, playCount, favorite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
song.id,
|
||||
song.title,
|
||||
song.artist,
|
||||
song.album,
|
||||
song.albumArtist,
|
||||
song.genre,
|
||||
song.trackNumber,
|
||||
song.discNumber,
|
||||
song.year,
|
||||
song.duration,
|
||||
song.filePath,
|
||||
song.fileSize,
|
||||
song.coverArtPath,
|
||||
song.addedAt,
|
||||
song.lastPlayedAt,
|
||||
song.playCount,
|
||||
song.favorite ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllSongs(
|
||||
orderBy: string = 'title',
|
||||
direction: 'ASC' | 'DESC' = 'ASC'
|
||||
): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const validColumns = ['title', 'artist', 'album', 'addedAt', 'playCount'];
|
||||
const col = validColumns.includes(orderBy) ? orderBy : 'title';
|
||||
const dir = direction === 'DESC' ? 'DESC' : 'ASC';
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
`SELECT * FROM songs ORDER BY ${col} ${dir}`
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getSongById(id: string): Promise<Song | null> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE id = ?',
|
||||
id
|
||||
);
|
||||
if (!row) return null;
|
||||
return { ...row, favorite: row.favorite === 1 };
|
||||
}
|
||||
|
||||
export async function deleteSong(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const song = await getSongById(id);
|
||||
if (song) {
|
||||
await deleteFile(song.filePath);
|
||||
if (song.coverArtPath) {
|
||||
await deleteFile(song.coverArtPath);
|
||||
}
|
||||
}
|
||||
await db.runAsync('DELETE FROM songs WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function toggleFavorite(id: string): Promise<boolean> {
|
||||
const db = await getDatabase();
|
||||
const song = await getSongById(id);
|
||||
if (!song) return false;
|
||||
const newFav = !song.favorite;
|
||||
await db.runAsync('UPDATE songs SET favorite = ? WHERE id = ?', newFav ? 1 : 0, id);
|
||||
return newFav;
|
||||
}
|
||||
|
||||
export async function updatePlayStats(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
'UPDATE songs SET playCount = playCount + 1, lastPlayedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSongDuration(id: string, duration: number): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('UPDATE songs SET duration = ? WHERE id = ?', duration, id);
|
||||
}
|
||||
|
||||
export async function getAlbums(): Promise<Album[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Album>(`
|
||||
SELECT
|
||||
album AS name,
|
||||
COALESCE(albumArtist, artist) AS artist,
|
||||
year,
|
||||
(SELECT coverArtPath FROM songs s2 WHERE s2.album = songs.album AND s2.coverArtPath IS NOT NULL LIMIT 1) AS coverArtPath,
|
||||
COUNT(*) AS songCount
|
||||
FROM songs
|
||||
WHERE album IS NOT NULL AND album != ''
|
||||
GROUP BY album
|
||||
ORDER BY album ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByAlbum(albumName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE album = ? ORDER BY discNumber ASC, trackNumber ASC, title ASC',
|
||||
albumName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<Artist[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Artist>(`
|
||||
SELECT
|
||||
artist AS name,
|
||||
COUNT(*) AS songCount,
|
||||
COUNT(DISTINCT album) AS albumCount
|
||||
FROM songs
|
||||
WHERE artist IS NOT NULL AND artist != ''
|
||||
GROUP BY artist
|
||||
ORDER BY artist ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByArtist(artistName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE artist = ? ORDER BY album ASC, trackNumber ASC, title ASC',
|
||||
artistName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getGenres(): Promise<Genre[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Genre>(`
|
||||
SELECT
|
||||
genre AS name,
|
||||
COUNT(*) AS songCount
|
||||
FROM songs
|
||||
WHERE genre IS NOT NULL AND genre != ''
|
||||
GROUP BY genre
|
||||
ORDER BY genre ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByGenre(genreName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE genre = ? ORDER BY artist ASC, album ASC, trackNumber ASC',
|
||||
genreName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function searchSongs(query: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const q = `%${query}%`;
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? ORDER BY title ASC LIMIT 50',
|
||||
q,
|
||||
q,
|
||||
q
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getSongCount(): Promise<number> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<{ count: number }>('SELECT COUNT(*) as count FROM songs');
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { Playlist, PlaylistSong, Song } from '~/types';
|
||||
|
||||
import { getDatabase } from './database';
|
||||
|
||||
export async function createPlaylist(name: string, description?: string): Promise<Playlist> {
|
||||
const db = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const playlist: Playlist = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description: description || null,
|
||||
coverArtPath: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.runAsync(
|
||||
'INSERT INTO playlists (id, name, description, coverArtPath, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
playlist.description,
|
||||
playlist.coverArtPath,
|
||||
playlist.createdAt,
|
||||
playlist.updatedAt
|
||||
);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
export async function getAllPlaylists(): Promise<Playlist[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Playlist>('SELECT * FROM playlists ORDER BY updatedAt DESC');
|
||||
}
|
||||
|
||||
export async function getPlaylistById(id: string): Promise<Playlist | null> {
|
||||
const db = await getDatabase();
|
||||
return db.getFirstAsync<Playlist>('SELECT * FROM playlists WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
id: string,
|
||||
updates: { name?: string; description?: string }
|
||||
): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const sets: string[] = ['updatedAt = ?'];
|
||||
const values: (string | null)[] = [new Date().toISOString()];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
sets.push('name = ?');
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
sets.push('description = ?');
|
||||
values.push(updates.description);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
await db.runAsync(`UPDATE playlists SET ${sets.join(', ')} WHERE id = ?`, ...values);
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('DELETE FROM playlists WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function addSongToPlaylist(playlistId: string, songId: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const maxOrder = await db.getFirstAsync<{ maxOrder: number | null }>(
|
||||
'SELECT MAX(sortOrder) as maxOrder FROM playlist_songs WHERE playlistId = ?',
|
||||
playlistId
|
||||
);
|
||||
const sortOrder = (maxOrder?.maxOrder ?? -1) + 1;
|
||||
|
||||
await db.runAsync(
|
||||
'INSERT INTO playlist_songs (id, playlistId, songId, sortOrder, addedAt) VALUES (?, ?, ?, ?, ?)',
|
||||
uuidv4(),
|
||||
playlistId,
|
||||
songId,
|
||||
sortOrder,
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
await db.runAsync(
|
||||
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
playlistId
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeSongFromPlaylist(playlistId: string, songId: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
'DELETE FROM playlist_songs WHERE playlistId = ? AND songId = ?',
|
||||
playlistId,
|
||||
songId
|
||||
);
|
||||
await db.runAsync(
|
||||
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
playlistId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
`SELECT s.* FROM songs s
|
||||
INNER JOIN playlist_songs ps ON s.id = ps.songId
|
||||
WHERE ps.playlistId = ?
|
||||
ORDER BY ps.sortOrder ASC`,
|
||||
playlistId
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getPlaylistSongCount(playlistId: string): Promise<number> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM playlist_songs WHERE playlistId = ?',
|
||||
playlistId
|
||||
);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import type { Song } from '~/types';
|
||||
|
||||
export interface QueueState {
|
||||
queue: Song[];
|
||||
originalQueue: Song[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export function createQueue(songs: Song[], startIndex: number = 0): QueueState {
|
||||
return {
|
||||
queue: [...songs],
|
||||
originalQueue: [...songs],
|
||||
currentIndex: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function shuffleQueue(state: QueueState): QueueState {
|
||||
const currentSong = state.queue[state.currentIndex];
|
||||
const remaining = state.queue.filter((_, i) => i !== state.currentIndex);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = remaining.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[remaining[i], remaining[j]] = [remaining[j], remaining[i]];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
queue: currentSong ? [currentSong, ...remaining] : remaining,
|
||||
currentIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function unshuffleQueue(state: QueueState): QueueState {
|
||||
const currentSong = state.queue[state.currentIndex];
|
||||
const newIndex = currentSong ? state.originalQueue.findIndex((s) => s.id === currentSong.id) : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
queue: [...state.originalQueue],
|
||||
currentIndex: Math.max(0, newIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextIndex(state: QueueState, repeatMode: 'off' | 'all' | 'one'): number | null {
|
||||
if (repeatMode === 'one') return state.currentIndex;
|
||||
if (state.currentIndex < state.queue.length - 1) return state.currentIndex + 1;
|
||||
if (repeatMode === 'all') return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPreviousIndex(
|
||||
state: QueueState,
|
||||
repeatMode: 'off' | 'all' | 'one'
|
||||
): number | null {
|
||||
if (repeatMode === 'one') return state.currentIndex;
|
||||
if (state.currentIndex > 0) return state.currentIndex - 1;
|
||||
if (repeatMode === 'all') return state.queue.length - 1;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { Album, Artist, Genre, LibraryTab, Song, SortDirection, SortField } from '~/types';
|
||||
import * as libraryService from '~/services/libraryService';
|
||||
|
||||
interface LibraryState {
|
||||
songs: Song[];
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
genres: Genre[];
|
||||
activeTab: LibraryTab;
|
||||
sortField: SortField;
|
||||
sortDirection: SortDirection;
|
||||
isLoading: boolean;
|
||||
songCount: number;
|
||||
|
||||
setActiveTab: (tab: LibraryTab) => void;
|
||||
setSortField: (field: SortField) => void;
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
loadSongs: () => Promise<void>;
|
||||
loadAlbums: () => Promise<void>;
|
||||
loadArtists: () => Promise<void>;
|
||||
loadGenres: () => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
toggleFavorite: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
songs: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
genres: [],
|
||||
activeTab: 'songs',
|
||||
sortField: 'title',
|
||||
sortDirection: 'asc',
|
||||
isLoading: false,
|
||||
songCount: 0,
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
setSortField: (field) => {
|
||||
set({ sortField: field });
|
||||
get().loadSongs();
|
||||
},
|
||||
|
||||
setSortDirection: (dir) => {
|
||||
set({ sortDirection: dir });
|
||||
get().loadSongs();
|
||||
},
|
||||
|
||||
loadSongs: async () => {
|
||||
const { sortField, sortDirection } = get();
|
||||
const songs = await libraryService.getAllSongs(
|
||||
sortField,
|
||||
sortDirection.toUpperCase() as 'ASC' | 'DESC'
|
||||
);
|
||||
set({ songs, songCount: songs.length });
|
||||
},
|
||||
|
||||
loadAlbums: async () => {
|
||||
const albums = await libraryService.getAlbums();
|
||||
set({ albums });
|
||||
},
|
||||
|
||||
loadArtists: async () => {
|
||||
const artists = await libraryService.getArtists();
|
||||
set({ artists });
|
||||
},
|
||||
|
||||
loadGenres: async () => {
|
||||
const genres = await libraryService.getGenres();
|
||||
set({ genres });
|
||||
},
|
||||
|
||||
loadAll: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await Promise.all([
|
||||
get().loadSongs(),
|
||||
get().loadAlbums(),
|
||||
get().loadArtists(),
|
||||
get().loadGenres(),
|
||||
]);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: async (id) => {
|
||||
const newFav = await libraryService.toggleFavorite(id);
|
||||
set((state) => ({
|
||||
songs: state.songs.map((s) => (s.id === id ? { ...s, favorite: newFav } : s)),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { RepeatMode, ShuffleMode, Song } from '~/types';
|
||||
import {
|
||||
createQueue,
|
||||
getNextIndex,
|
||||
getPreviousIndex,
|
||||
shuffleQueue,
|
||||
unshuffleQueue,
|
||||
type QueueState,
|
||||
} from '~/services/queueService';
|
||||
|
||||
interface PlayerState {
|
||||
currentSong: Song | null;
|
||||
isPlaying: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleMode: ShuffleMode;
|
||||
queueState: QueueState;
|
||||
|
||||
playSong: (song: Song, queue?: Song[], startIndex?: number) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setPosition: (position: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
toggleRepeat: () => void;
|
||||
toggleShuffle: () => void;
|
||||
nextSong: () => Song | null;
|
||||
previousSong: () => Song | null;
|
||||
getQueue: () => Song[];
|
||||
clearQueue: () => void;
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentSong: null,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
repeatMode: 'off',
|
||||
shuffleMode: 'off',
|
||||
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
|
||||
|
||||
playSong: (song, queue, startIndex) => {
|
||||
let queueState: QueueState;
|
||||
if (queue && queue.length > 0) {
|
||||
const idx = startIndex ?? queue.findIndex((s) => s.id === song.id);
|
||||
queueState = createQueue(queue, Math.max(0, idx));
|
||||
if (get().shuffleMode === 'on') {
|
||||
queueState = shuffleQueue(queueState);
|
||||
}
|
||||
} else {
|
||||
queueState = createQueue([song], 0);
|
||||
}
|
||||
set({ currentSong: song, isPlaying: true, position: 0, duration: 0, queueState });
|
||||
},
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPosition: (position) => set({ position }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
|
||||
toggleRepeat: () => {
|
||||
const modes: RepeatMode[] = ['off', 'all', 'one'];
|
||||
const current = modes.indexOf(get().repeatMode);
|
||||
set({ repeatMode: modes[(current + 1) % modes.length] });
|
||||
},
|
||||
|
||||
toggleShuffle: () => {
|
||||
const { shuffleMode, queueState } = get();
|
||||
if (shuffleMode === 'off') {
|
||||
set({
|
||||
shuffleMode: 'on',
|
||||
queueState: shuffleQueue(queueState),
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
shuffleMode: 'off',
|
||||
queueState: unshuffleQueue(queueState),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
nextSong: () => {
|
||||
const { queueState, repeatMode } = get();
|
||||
const nextIdx = getNextIndex(queueState, repeatMode);
|
||||
if (nextIdx === null) {
|
||||
set({ isPlaying: false });
|
||||
return null;
|
||||
}
|
||||
const song = queueState.queue[nextIdx];
|
||||
set({
|
||||
currentSong: song,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
isPlaying: true,
|
||||
queueState: { ...queueState, currentIndex: nextIdx },
|
||||
});
|
||||
return song;
|
||||
},
|
||||
|
||||
previousSong: () => {
|
||||
const { queueState, repeatMode, position } = get();
|
||||
// If more than 3 seconds in, restart current song
|
||||
if (position > 3) {
|
||||
set({ position: 0 });
|
||||
return get().currentSong;
|
||||
}
|
||||
const prevIdx = getPreviousIndex(queueState, repeatMode);
|
||||
if (prevIdx === null) {
|
||||
set({ position: 0 });
|
||||
return get().currentSong;
|
||||
}
|
||||
const song = queueState.queue[prevIdx];
|
||||
set({
|
||||
currentSong: song,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
isPlaying: true,
|
||||
queueState: { ...queueState, currentIndex: prevIdx },
|
||||
});
|
||||
return song;
|
||||
},
|
||||
|
||||
getQueue: () => get().queueState.queue,
|
||||
|
||||
clearQueue: () => {
|
||||
set({
|
||||
currentSong: null,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { Playlist } from '~/types';
|
||||
import * as playlistService from '~/services/playlistService';
|
||||
|
||||
interface PlaylistState {
|
||||
playlists: Playlist[];
|
||||
isLoading: boolean;
|
||||
|
||||
loadPlaylists: () => Promise<void>;
|
||||
createPlaylist: (name: string, description?: string) => Promise<Playlist>;
|
||||
deletePlaylist: (id: string) => Promise<void>;
|
||||
updatePlaylist: (id: string, updates: { name?: string; description?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlaylistStore = create<PlaylistState>((set, get) => ({
|
||||
playlists: [],
|
||||
isLoading: false,
|
||||
|
||||
loadPlaylists: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const playlists = await playlistService.getAllPlaylists();
|
||||
set({ playlists });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createPlaylist: async (name, description) => {
|
||||
const playlist = await playlistService.createPlaylist(name, description);
|
||||
set((state) => ({ playlists: [playlist, ...state.playlists] }));
|
||||
return playlist;
|
||||
},
|
||||
|
||||
deletePlaylist: async (id) => {
|
||||
await playlistService.deletePlaylist(id);
|
||||
set((state) => ({ playlists: state.playlists.filter((p) => p.id !== id) }));
|
||||
},
|
||||
|
||||
updatePlaylist: async (id, updates) => {
|
||||
await playlistService.updatePlaylist(id, updates);
|
||||
await get().loadPlaylists();
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#FF6B35',
|
||||
'primary-dark': '#E55A2B',
|
||||
accent: '#FF8F65',
|
||||
background: {
|
||||
light: '#FFFFFF',
|
||||
dark: '#121212',
|
||||
},
|
||||
text: {
|
||||
light: '#000000',
|
||||
dark: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export type {
|
||||
Song,
|
||||
Album,
|
||||
Artist,
|
||||
Genre,
|
||||
Playlist,
|
||||
PlaylistSong,
|
||||
RepeatMode,
|
||||
ShuffleMode,
|
||||
LibraryTab,
|
||||
SortField,
|
||||
SortDirection,
|
||||
SortOption,
|
||||
} from '@mukke/types';
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
export type ThemeVariant = 'classic' | 'ocean' | 'sunset';
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
border: string;
|
||||
card: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
const THEME_VARIANTS = {
|
||||
classic: {
|
||||
primary: '#FF6B35',
|
||||
primaryDark: '#E55A2B',
|
||||
accent: '#FF8F65',
|
||||
},
|
||||
ocean: {
|
||||
primary: '#2196F3',
|
||||
primaryDark: '#1976D2',
|
||||
accent: '#42A5F5',
|
||||
},
|
||||
sunset: {
|
||||
primary: '#FF6B6B',
|
||||
primaryDark: '#FF5252',
|
||||
accent: '#FF8A80',
|
||||
},
|
||||
};
|
||||
|
||||
const getThemeColors = (variant: ThemeVariant, isDark: boolean): ThemeColors => {
|
||||
const variantColors = THEME_VARIANTS[variant];
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
primary: variantColors.primary,
|
||||
primaryDark: variantColors.primaryDark,
|
||||
accent: variantColors.accent,
|
||||
background: '#121212',
|
||||
backgroundSecondary: '#1E1E1E',
|
||||
backgroundTertiary: '#2D2D2D',
|
||||
text: '#FFFFFF',
|
||||
textSecondary: '#AAAAAA',
|
||||
textTertiary: '#888888',
|
||||
border: '#333333',
|
||||
card: '#1E1E1E',
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#FF6B6B',
|
||||
shadow: '#000000',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
primary: variantColors.primary,
|
||||
primaryDark: variantColors.primaryDark,
|
||||
accent: variantColors.accent,
|
||||
background: '#F5F5F5',
|
||||
backgroundSecondary: '#FFFFFF',
|
||||
backgroundTertiary: '#F0F0F0',
|
||||
text: '#000000',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
border: '#E0E0E0',
|
||||
card: '#FFFFFF',
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#F44336',
|
||||
shadow: '#000000',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
isDarkMode: boolean;
|
||||
themeVariant: ThemeVariant;
|
||||
colors: ThemeColors;
|
||||
toggleTheme: () => void;
|
||||
setDarkMode: (isDark: boolean) => void;
|
||||
setThemeVariant: (variant: ThemeVariant) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
isDarkMode: false,
|
||||
themeVariant: 'classic',
|
||||
colors: getThemeColors('classic', false),
|
||||
toggleTheme: () => {},
|
||||
setDarkMode: () => {},
|
||||
setThemeVariant: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
const THEME_PREFERENCE_KEY = '@mukke_theme_preference';
|
||||
const THEME_VARIANT_KEY = '@mukke_theme_variant';
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode | ((themeProps: ThemeContextType) => React.ReactNode);
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [themeVariant, setThemeVariantState] = useState<ThemeVariant>('classic');
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadThemePreferences = async () => {
|
||||
try {
|
||||
const [savedPreference, savedVariant] = await Promise.all([
|
||||
AsyncStorage.getItem(THEME_PREFERENCE_KEY),
|
||||
AsyncStorage.getItem(THEME_VARIANT_KEY),
|
||||
]);
|
||||
|
||||
if (savedPreference !== null) {
|
||||
setIsDarkMode(savedPreference === 'dark');
|
||||
} else {
|
||||
setIsDarkMode(systemColorScheme === 'dark');
|
||||
}
|
||||
|
||||
if (savedVariant !== null && ['classic', 'ocean', 'sunset'].includes(savedVariant)) {
|
||||
setThemeVariantState(savedVariant as ThemeVariant);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme preferences', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadThemePreferences();
|
||||
}, [systemColorScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
AsyncStorage.setItem(THEME_PREFERENCE_KEY, isDarkMode ? 'dark' : 'light').catch((error) =>
|
||||
console.error('Failed to save theme preference', error)
|
||||
);
|
||||
}
|
||||
}, [isDarkMode, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
AsyncStorage.setItem(THEME_VARIANT_KEY, themeVariant).catch((error) =>
|
||||
console.error('Failed to save theme variant', error)
|
||||
);
|
||||
}
|
||||
}, [themeVariant, isLoaded]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDarkMode((prev) => !prev);
|
||||
};
|
||||
|
||||
const setDarkMode = (isDark: boolean) => {
|
||||
setIsDarkMode(isDark);
|
||||
};
|
||||
|
||||
const setThemeVariant = (variant: ThemeVariant) => {
|
||||
setThemeVariantState(variant);
|
||||
};
|
||||
|
||||
const colors = getThemeColors(themeVariant, isDarkMode);
|
||||
|
||||
const themeContextValue = {
|
||||
isDarkMode,
|
||||
themeVariant,
|
||||
colors,
|
||||
toggleTheme,
|
||||
setDarkMode,
|
||||
setThemeVariant,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={themeContextValue}>
|
||||
{typeof children === 'function' ? children(themeContextValue) : children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"name": "mukke",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Mukke - Offline-first iOS Music Player",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@mukke/* --parallel dev"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "@mukke/types",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumArtist: string | null;
|
||||
genre: string | null;
|
||||
trackNumber: number | null;
|
||||
discNumber: number | null;
|
||||
year: number | null;
|
||||
duration: number | null;
|
||||
filePath: string;
|
||||
fileSize: number | null;
|
||||
coverArtPath: string | null;
|
||||
addedAt: string;
|
||||
lastPlayedAt: string | null;
|
||||
playCount: number;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
name: string;
|
||||
artist: string | null;
|
||||
year: number | null;
|
||||
coverArtPath: string | null;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
name: string;
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
name: string;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
coverArtPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistSong {
|
||||
id: string;
|
||||
playlistId: string;
|
||||
songId: string;
|
||||
sortOrder: number;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export type RepeatMode = 'off' | 'all' | 'one';
|
||||
export type ShuffleMode = 'off' | 'on';
|
||||
|
||||
export type LibraryTab = 'songs' | 'albums' | 'artists' | 'genres';
|
||||
|
||||
export type SortField = 'title' | 'artist' | 'album' | 'addedAt' | 'playCount';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortOption {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
label: string;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue