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:
Till JS 2026-03-23 11:06:40 +01:00
parent 7910737dd9
commit ea7962501d
65 changed files with 41 additions and 3825 deletions

View file

@ -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

View file

@ -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"
}
}
]
}

View file

@ -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
View file

@ -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

View file

@ -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

View file

@ -1,6 +0,0 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View file

@ -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"
}
}
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -1,10 +0,0 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View file

@ -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>
)}
/>
);
}

View file

@ -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
/>
)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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)}
/>
)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 }}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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": {}
}
}

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -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' });

View file

@ -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.

View file

@ -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
}

View file

@ -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')}`;
}

View file

@ -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;
}
}

View file

@ -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`;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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)),
}));
},
}));

View file

@ -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 },
});
},
}));

View file

@ -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();
},
}));

View file

@ -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: [],
};

View file

@ -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"]
}

View file

@ -1,14 +0,0 @@
export type {
Song,
Album,
Artist,
Genre,
Playlist,
PlaylistSong,
RepeatMode,
ShuffleMode,
LibraryTab,
SortField,
SortDirection,
SortOption,
} from '@mukke/types';

View file

@ -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>
);
};

View file

@ -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"
}
}

View file

@ -1,7 +0,0 @@
{
"name": "@mukke/types",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true
}

View file

@ -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;
}

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}