From 6c91805b2f099b2c1ac40a9c389bb45c26d9b949 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 17 Mar 2026 13:23:58 +0100 Subject: [PATCH] feat(mukke): add offline-first iOS music player app Mukke is a local, offline-first music player for iOS. Songs are imported from iCloud/local files via document picker, stored on device, and played with expo-audio including background playback and lock screen controls. Stack: Expo SDK 55, expo-audio, expo-sqlite, expo-document-picker, @missingcore/audio-metadata, Zustand, NativeWind, Expo Router with NativeTabs. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/matrix/apps/mobile/.gitignore | 6 + apps/matrix/apps/mobile/app/(app)/dms.tsx | 8 +- apps/matrix/apps/mobile/app/(app)/index.tsx | 12 +- apps/matrix/apps/mobile/app/(app)/invites.tsx | 36 +-- .../matrix/apps/mobile/app/(app)/settings.tsx | 63 ++--- apps/matrix/apps/mobile/app/(auth)/login.tsx | 23 +- apps/matrix/apps/mobile/app/room/[id].tsx | 237 +++++++++++++----- apps/matrix/apps/mobile/app/room/new.tsx | 11 +- apps/matrix/apps/mobile/app/room/settings.tsx | 40 ++- apps/matrix/apps/mobile/app/search.tsx | 31 +-- apps/matrix/apps/mobile/metro.config.js | 33 ++- apps/matrix/apps/mobile/package.json | 4 +- .../mobile/src/components/ImageViewer.tsx | 8 +- .../mobile/src/components/MessageBubble.tsx | 11 +- .../mobile/src/components/MessageInput.tsx | 32 +-- .../mobile/src/components/RoomListItem.tsx | 14 +- .../src/components/UserProfileModal.tsx | 46 ++-- .../mobile/src/components/VoiceMessage.tsx | 4 +- .../mobile/src/components/VoiceRecorder.tsx | 8 +- apps/mukke/CLAUDE.md | 60 +++++ apps/mukke/apps/mobile/.gitignore | 6 + apps/mukke/apps/mobile/app.json | 53 ++++ apps/mukke/apps/mobile/app/(tabs)/_layout.tsx | 33 +++ apps/mukke/apps/mobile/app/(tabs)/index.tsx | 72 ++++++ .../apps/mobile/app/(tabs)/playlists.tsx | 70 ++++++ apps/mukke/apps/mobile/app/(tabs)/search.tsx | 79 ++++++ .../mukke/apps/mobile/app/(tabs)/settings.tsx | 187 ++++++++++++++ apps/mukke/apps/mobile/app/+not-found.tsx | 16 ++ apps/mukke/apps/mobile/app/_layout.tsx | 78 ++++++ apps/mukke/apps/mobile/app/album/[id].tsx | 53 ++++ apps/mukke/apps/mobile/app/artist/[id].tsx | 47 ++++ apps/mukke/apps/mobile/app/player.tsx | 102 ++++++++ apps/mukke/apps/mobile/app/playlist/[id].tsx | 118 +++++++++ apps/mukke/apps/mobile/app/playlist/new.tsx | 84 +++++++ apps/mukke/apps/mobile/app/queue.tsx | 77 ++++++ .../apps/mobile/assets/adaptive-icon.png | Bin 0 -> 4555 bytes apps/mukke/apps/mobile/assets/favicon.png | Bin 0 -> 115 bytes apps/mukke/apps/mobile/assets/icon.png | Bin 0 -> 4555 bytes apps/mukke/apps/mobile/assets/splash.png | Bin 0 -> 14314 bytes apps/mukke/apps/mobile/babel.config.js | 10 + .../apps/mobile/components/AlbumGrid.tsx | 75 ++++++ .../apps/mobile/components/ArtistList.tsx | 43 ++++ apps/mukke/apps/mobile/components/Artwork.tsx | 42 ++++ apps/mukke/apps/mobile/components/Button.tsx | 45 ++++ .../apps/mobile/components/EmptyState.tsx | 43 ++++ .../apps/mobile/components/GenreList.tsx | 65 +++++ .../apps/mobile/components/ImportButton.tsx | 36 +++ .../mukke/apps/mobile/components/ListItem.tsx | 66 +++++ .../apps/mobile/components/MiniPlayer.tsx | 77 ++++++ .../apps/mobile/components/ProgressBar.tsx | 37 +++ .../mobile/components/SegmentedControl.tsx | 57 +++++ .../mukke/apps/mobile/components/SongList.tsx | 54 ++++ .../apps/mobile/components/SongPicker.tsx | 110 ++++++++ .../mukke/apps/mobile/components/SortMenu.tsx | 95 +++++++ .../apps/mobile/components/ThemeWrapper.tsx | 20 ++ .../mobile/components/TransportControls.tsx | 104 ++++++++ .../apps/mobile/contexts/AudioContext.tsx | 140 +++++++++++ apps/mukke/apps/mobile/eas.json | 38 +++ apps/mukke/apps/mobile/global.css | 3 + apps/mukke/apps/mobile/metro.config.js | 25 ++ apps/mukke/apps/mobile/nativewind-env.d.ts | 3 + apps/mukke/apps/mobile/package.json | 66 +++++ .../apps/mobile/services/audioService.ts | 17 ++ apps/mukke/apps/mobile/services/database.ts | 67 +++++ .../mukke/apps/mobile/services/fileService.ts | 102 ++++++++ .../apps/mobile/services/importService.ts | 104 ++++++++ .../apps/mobile/services/libraryService.ts | 176 +++++++++++++ .../apps/mobile/services/playlistService.ts | 123 +++++++++ .../apps/mobile/services/queueService.ts | 60 +++++ apps/mukke/apps/mobile/stores/libraryStore.ts | 95 +++++++ apps/mukke/apps/mobile/stores/playerStore.ts | 134 ++++++++++ .../mukke/apps/mobile/stores/playlistStore.ts | 45 ++++ apps/mukke/apps/mobile/tailwind.config.js | 24 ++ apps/mukke/apps/mobile/tsconfig.json | 12 + apps/mukke/apps/mobile/types/index.ts | 14 ++ apps/mukke/apps/mobile/utils/themeContext.tsx | 190 ++++++++++++++ apps/mukke/package.json | 9 + apps/mukke/packages/mukke-types/package.json | 7 + apps/mukke/packages/mukke-types/src/index.ts | 69 +++++ apps/mukke/packages/mukke-types/tsconfig.json | 12 + 80 files changed, 4035 insertions(+), 241 deletions(-) create mode 100644 apps/mukke/CLAUDE.md create mode 100644 apps/mukke/apps/mobile/.gitignore create mode 100644 apps/mukke/apps/mobile/app.json create mode 100644 apps/mukke/apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/mukke/apps/mobile/app/(tabs)/index.tsx create mode 100644 apps/mukke/apps/mobile/app/(tabs)/playlists.tsx create mode 100644 apps/mukke/apps/mobile/app/(tabs)/search.tsx create mode 100644 apps/mukke/apps/mobile/app/(tabs)/settings.tsx create mode 100644 apps/mukke/apps/mobile/app/+not-found.tsx create mode 100644 apps/mukke/apps/mobile/app/_layout.tsx create mode 100644 apps/mukke/apps/mobile/app/album/[id].tsx create mode 100644 apps/mukke/apps/mobile/app/artist/[id].tsx create mode 100644 apps/mukke/apps/mobile/app/player.tsx create mode 100644 apps/mukke/apps/mobile/app/playlist/[id].tsx create mode 100644 apps/mukke/apps/mobile/app/playlist/new.tsx create mode 100644 apps/mukke/apps/mobile/app/queue.tsx create mode 100644 apps/mukke/apps/mobile/assets/adaptive-icon.png create mode 100644 apps/mukke/apps/mobile/assets/favicon.png create mode 100644 apps/mukke/apps/mobile/assets/icon.png create mode 100644 apps/mukke/apps/mobile/assets/splash.png create mode 100644 apps/mukke/apps/mobile/babel.config.js create mode 100644 apps/mukke/apps/mobile/components/AlbumGrid.tsx create mode 100644 apps/mukke/apps/mobile/components/ArtistList.tsx create mode 100644 apps/mukke/apps/mobile/components/Artwork.tsx create mode 100644 apps/mukke/apps/mobile/components/Button.tsx create mode 100644 apps/mukke/apps/mobile/components/EmptyState.tsx create mode 100644 apps/mukke/apps/mobile/components/GenreList.tsx create mode 100644 apps/mukke/apps/mobile/components/ImportButton.tsx create mode 100644 apps/mukke/apps/mobile/components/ListItem.tsx create mode 100644 apps/mukke/apps/mobile/components/MiniPlayer.tsx create mode 100644 apps/mukke/apps/mobile/components/ProgressBar.tsx create mode 100644 apps/mukke/apps/mobile/components/SegmentedControl.tsx create mode 100644 apps/mukke/apps/mobile/components/SongList.tsx create mode 100644 apps/mukke/apps/mobile/components/SongPicker.tsx create mode 100644 apps/mukke/apps/mobile/components/SortMenu.tsx create mode 100644 apps/mukke/apps/mobile/components/ThemeWrapper.tsx create mode 100644 apps/mukke/apps/mobile/components/TransportControls.tsx create mode 100644 apps/mukke/apps/mobile/contexts/AudioContext.tsx create mode 100644 apps/mukke/apps/mobile/eas.json create mode 100644 apps/mukke/apps/mobile/global.css create mode 100644 apps/mukke/apps/mobile/metro.config.js create mode 100644 apps/mukke/apps/mobile/nativewind-env.d.ts create mode 100644 apps/mukke/apps/mobile/package.json create mode 100644 apps/mukke/apps/mobile/services/audioService.ts create mode 100644 apps/mukke/apps/mobile/services/database.ts create mode 100644 apps/mukke/apps/mobile/services/fileService.ts create mode 100644 apps/mukke/apps/mobile/services/importService.ts create mode 100644 apps/mukke/apps/mobile/services/libraryService.ts create mode 100644 apps/mukke/apps/mobile/services/playlistService.ts create mode 100644 apps/mukke/apps/mobile/services/queueService.ts create mode 100644 apps/mukke/apps/mobile/stores/libraryStore.ts create mode 100644 apps/mukke/apps/mobile/stores/playerStore.ts create mode 100644 apps/mukke/apps/mobile/stores/playlistStore.ts create mode 100644 apps/mukke/apps/mobile/tailwind.config.js create mode 100644 apps/mukke/apps/mobile/tsconfig.json create mode 100644 apps/mukke/apps/mobile/types/index.ts create mode 100644 apps/mukke/apps/mobile/utils/themeContext.tsx create mode 100644 apps/mukke/package.json create mode 100644 apps/mukke/packages/mukke-types/package.json create mode 100644 apps/mukke/packages/mukke-types/src/index.ts create mode 100644 apps/mukke/packages/mukke-types/tsconfig.json diff --git a/apps/matrix/apps/mobile/.gitignore b/apps/matrix/apps/mobile/.gitignore index 9391d962d..526492a93 100644 --- a/apps/matrix/apps/mobile/.gitignore +++ b/apps/matrix/apps/mobile/.gitignore @@ -13,3 +13,9 @@ android/ web-build/ .env .env.local + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/matrix/apps/mobile/app/(app)/dms.tsx b/apps/matrix/apps/mobile/app/(app)/dms.tsx index 1a71f5ced..7ca1ad97c 100644 --- a/apps/matrix/apps/mobile/app/(app)/dms.tsx +++ b/apps/matrix/apps/mobile/app/(app)/dms.tsx @@ -21,7 +21,7 @@ export default function DMsScreen() { const dmInvites = useMemo( () => rooms.filter((r) => r.membership === 'invite' && r.isDirect), - [rooms], + [rooms] ); const handleRoomPress = (roomId: string) => { @@ -37,9 +37,7 @@ export default function DMsScreen() { Direct Messages router.push('/room/new')} - className={({ pressed }) => - `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}` - } + className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70" > @@ -69,7 +67,7 @@ export default function DMsScreen() { renderItem={({ item }) => ( handleRoomPress(item.id)} /> )} - contentContainerClassName="pb-4" + contentContainerStyle={{ paddingBottom: 16 }} ListHeaderComponent={ dmInvites.length > 0 ? ( diff --git a/apps/matrix/apps/mobile/app/(app)/index.tsx b/apps/matrix/apps/mobile/app/(app)/index.tsx index 08a8d57ab..cb30e8bbb 100644 --- a/apps/matrix/apps/mobile/app/(app)/index.tsx +++ b/apps/matrix/apps/mobile/app/(app)/index.tsx @@ -22,7 +22,7 @@ export default function ChatsScreen() { // Pending invites const invites = useMemo( () => rooms.filter((r) => r.membership === 'invite' && !r.isDirect), - [rooms], + [rooms] ); const handleRoomPress = (roomId: string) => { @@ -40,17 +40,13 @@ export default function ChatsScreen() { router.push('/search')} - className={({ pressed }) => - `w-9 h-9 bg-surface border border-border rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}` - } + className="w-9 h-9 bg-surface border border-border rounded-full items-center justify-center active:opacity-70" > router.push('/room/new')} - className={({ pressed }) => - `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}` - } + className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70" > @@ -84,7 +80,7 @@ export default function ChatsScreen() { renderItem={({ item }) => ( handleRoomPress(item.id)} /> )} - contentContainerClassName="pb-4" + contentContainerStyle={{ paddingBottom: 16 }} ListHeaderComponent={ invites.length > 0 ? ( diff --git a/apps/matrix/apps/mobile/app/(app)/invites.tsx b/apps/matrix/apps/mobile/app/(app)/invites.tsx index c42fc3c5a..7307f3836 100644 --- a/apps/matrix/apps/mobile/app/(app)/invites.tsx +++ b/apps/matrix/apps/mobile/app/(app)/invites.tsx @@ -4,14 +4,26 @@ import { Image } from 'expo-image'; import { useMatrixStore } from '~/src/matrix/store'; import type { SimpleRoom } from '~/src/matrix/types'; -function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: () => void; onDecline: () => void }) { +function InviteCard({ + room, + onAccept, + onDecline, +}: { + room: SimpleRoom; + onAccept: () => void; + onDecline: () => void; +}) { return ( {/* Avatar */} {room.avatar ? ( - + ) : ( {(room.name ?? '?')[0].toUpperCase()} @@ -30,17 +42,15 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: )} {room.inviter && ( - - Invited by {room.inviter} - + Invited by {room.inviter} )} - {room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`} + {room.isDirect + ? 'Direct message' + : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`} - {room.isEncrypted && ( - · 🔒 Encrypted - )} + {room.isEncrypted && · 🔒 Encrypted} @@ -49,15 +59,13 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: - `flex-1 py-3 items-center border-r border-border ${pressed ? 'bg-surface' : ''}` - } + className="flex-1 py-3 items-center border-r border-border active:bg-surface" > Decline `flex-1 py-3 items-center ${pressed ? 'bg-primary/80' : 'bg-primary'}`} + className="flex-1 py-3 items-center bg-primary active:bg-primary/80" > Accept @@ -114,7 +122,7 @@ export default function InvitesScreen() { onDecline={() => handleDecline(item.id, item.name)} /> )} - contentContainerClassName="pt-2 pb-6" + contentContainerStyle={{ paddingTop: 8, paddingBottom: 24 }} ListEmptyComponent={ ✉️ diff --git a/apps/matrix/apps/mobile/app/(app)/settings.tsx b/apps/matrix/apps/mobile/app/(app)/settings.tsx index 973b4948a..2f2dc515b 100644 --- a/apps/matrix/apps/mobile/app/(app)/settings.tsx +++ b/apps/matrix/apps/mobile/app/(app)/settings.tsx @@ -41,17 +41,19 @@ export default function SettingsScreen() { const [uploadingAvatar, setUploadingAvatar] = useState(false); // Get current profile from client - const profileInfo = client ? (() => { - try { - const user = client.getUser(userId); - return { - displayName: user?.displayName ?? userId.split(':')[0].slice(1), - avatarUrl: user?.avatarUrl ?? undefined, - }; - } catch { - return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined }; - } - })() : { displayName: '', avatarUrl: undefined }; + const profileInfo = client + ? (() => { + try { + const user = client.getUser(userId); + return { + displayName: user?.displayName ?? userId.split(':')[0].slice(1), + avatarUrl: user?.avatarUrl ?? undefined, + }; + } catch { + return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined }; + } + })() + : { displayName: '', avatarUrl: undefined }; const handleEditName = () => { setNewDisplayName(profileInfo.displayName); @@ -107,15 +109,11 @@ export default function SettingsScreen() { Settings - + {/* Profile card */} {/* Avatar */} - + - Connection + + Connection + Homeserver - {homeserver || '—'} + + {homeserver || '—'} + Sync status - - {syncState.toLowerCase()} + + + {syncState.toLowerCase()} + @@ -189,16 +195,19 @@ export default function SettingsScreen() { {/* Sign out */} - `bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center ${pressed ? 'opacity-60' : ''}` - } + className="bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center active:opacity-60" > Sign out {/* Edit display name modal */} - setEditingName(false)}> + setEditingName(false)} + > @@ -218,9 +227,7 @@ export default function SettingsScreen() { - `bg-primary rounded-xl py-3 items-center ${pressed || savingName || !newDisplayName.trim() ? 'opacity-60' : ''}` - } + className="bg-primary rounded-xl py-3 items-center active:opacity-60" > {savingName ? ( diff --git a/apps/matrix/apps/mobile/app/(auth)/login.tsx b/apps/matrix/apps/mobile/app/(auth)/login.tsx index 9378ef684..252aaa79e 100644 --- a/apps/matrix/apps/mobile/app/(auth)/login.tsx +++ b/apps/matrix/apps/mobile/app/(auth)/login.tsx @@ -92,7 +92,7 @@ export default function LoginScreen() { base, response.access_token, response.user_id, - response.device_id, + response.device_id ); if (loginResult.success && loginResult.credentials) { await initialize(loginResult.credentials); @@ -112,7 +112,7 @@ export default function LoginScreen() { className="flex-1" > {/* Logo */} @@ -134,7 +134,10 @@ export default function LoginScreen() { { setHomeserver(v); setServerOk(null); }} + onChangeText={(v) => { + setHomeserver(v); + setServerOk(null); + }} autoCapitalize="none" autoCorrect={false} keyboardType="url" @@ -187,9 +190,7 @@ export default function LoginScreen() { - `bg-primary rounded-xl py-4 items-center mt-1 ${pressed || loading ? 'opacity-70' : ''}` - } + className="bg-primary rounded-xl py-4 items-center mt-1 active:opacity-70" > {loading ? ( @@ -209,18 +210,12 @@ export default function LoginScreen() { - `bg-surface border border-border rounded-xl py-4 items-center ${ - pressed || ssoLoading ? 'opacity-70' : '' - }` - } + className="bg-surface border border-border rounded-xl py-4 items-center active:opacity-70" > {ssoLoading ? ( ) : ( - - Sign in with SSO - + Sign in with SSO )} diff --git a/apps/matrix/apps/mobile/app/room/[id].tsx b/apps/matrix/apps/mobile/app/room/[id].tsx index a37abe9e1..35c10e986 100644 --- a/apps/matrix/apps/mobile/app/room/[id].tsx +++ b/apps/matrix/apps/mobile/app/room/[id].tsx @@ -36,10 +36,13 @@ type ListItem = | { type: 'unread'; key: string }; function isSameDay(a: number, b: number) { - const da = new Date(a), db = new Date(b); - return da.getFullYear() === db.getFullYear() && + const da = new Date(a), + db = new Date(b); + return ( + da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && - da.getDate() === db.getDate(); + da.getDate() === db.getDate() + ); } function buildListItems(messages: SimpleMessage[], firstUnreadEventId: string | null): ListItem[] { @@ -65,11 +68,15 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi <> setShowProfile(true)} - className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`} + className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60" > {member.avatarUrl ? ( - + ) : ( {member.displayName[0]?.toUpperCase() ?? '?'} @@ -78,7 +85,9 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi {member.displayName} - {member.userId} + + {member.userId} + {member.powerLevel >= 100 && ( @@ -93,7 +102,10 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi { setShowProfile(false); onClose(); }} + onClose={() => { + setShowProfile(false); + onClose(); + }} /> ); @@ -116,10 +128,25 @@ export default function RoomScreen() { const [forwardSearch, setForwardSearch] = useState(''); const { - rooms, messages, firstUnreadEventId, typingUsers, roomMembers, client, credentials, - selectRoom, loadRoomMembers, sendMessage, editMessage, - sendReaction, redactMessage, sendTyping, - sendImage, sendFile, sendVoice, forwardMessage, leaveRoom, + rooms, + messages, + firstUnreadEventId, + typingUsers, + roomMembers, + client, + credentials, + selectRoom, + loadRoomMembers, + sendMessage, + editMessage, + sendReaction, + redactMessage, + sendTyping, + sendImage, + sendFile, + sendVoice, + forwardMessage, + leaveRoom, } = useMatrixStore(); const room = rooms.find((r) => r.id === id); @@ -130,9 +157,14 @@ export default function RoomScreen() { return (matrixRoom?.getMember(userId)?.powerLevel ?? 0) >= 100; }, [client, id]); - useEffect(() => { if (id) selectRoom(id); }, [id]); + useEffect(() => { + if (id) selectRoom(id); + }, [id]); - const listItems = useMemo(() => buildListItems(messages, firstUnreadEventId), [messages, firstUnreadEventId]); + const listItems = useMemo( + () => buildListItems(messages, firstUnreadEventId), + [messages, firstUnreadEventId] + ); // Scroll to first unread message on initial load useEffect(() => { @@ -150,8 +182,11 @@ export default function RoomScreen() { const matrixRoom = client.getRoom(id); if (!matrixRoom) return; setLoadingMore(true); - try { await client.scrollback(matrixRoom, 30); } - finally { setLoadingMore(false); } + try { + await client.scrollback(matrixRoom, 30); + } finally { + setLoadingMore(false); + } }; const handleRoomOptions = () => { @@ -162,15 +197,33 @@ export default function RoomScreen() { ActionSheetIOS.showActionSheetWithOptions( { options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex }, (index) => { - if (index === 1) { loadRoomMembers(id!); setShowMembers(true); } - if (isAdmin && index === 2) { router.push({ pathname: '/room/settings', params: { id } }); } + if (index === 1) { + loadRoomMembers(id!); + setShowMembers(true); + } + if (isAdmin && index === 2) { + router.push({ pathname: '/room/settings', params: { id } }); + } if (index === options.length - 1) handleLeave(); - }, + } ); } else { Alert.alert(room?.name ?? 'Room', undefined, [ - { text: 'Members', onPress: () => { loadRoomMembers(id!); setShowMembers(true); } }, - ...(isAdmin ? [{ text: 'Room settings', onPress: () => router.push({ pathname: '/room/settings', params: { id } }) }] : []), + { + text: 'Members', + onPress: () => { + loadRoomMembers(id!); + setShowMembers(true); + }, + }, + ...(isAdmin + ? [ + { + text: 'Room settings', + onPress: () => router.push({ pathname: '/room/settings', params: { id } }), + }, + ] + : []), { text: 'Leave room', style: 'destructive' as const, onPress: handleLeave }, { text: 'Cancel', style: 'cancel' as const }, ]); @@ -199,7 +252,7 @@ export default function RoomScreen() { if (index === 1) pickImage('library'); if (index === 2) pickImage('camera'); if (index === 3) pickDocument(); - }, + } ); } else { Alert.alert('Attach', undefined, [ @@ -212,17 +265,26 @@ export default function RoomScreen() { }; const pickImage = async (source: 'library' | 'camera') => { - const fn = source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync; + const fn = + source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync; const result = await fn({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.85 }); if (result.canceled || !result.assets[0]) return; const asset = result.assets[0]; const filename = asset.fileName ?? `image_${Date.now()}.jpg`; setUploading(true); try { - await sendImage(asset.uri, filename, asset.mimeType ?? getMimetypeFromFilename(filename), asset.width, asset.height); + await sendImage( + asset.uri, + filename, + asset.mimeType ?? getMimetypeFromFilename(filename), + asset.width, + asset.height + ); } catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { setUploading(false); } + } finally { + setUploading(false); + } }; const pickDocument = async () => { @@ -234,7 +296,9 @@ export default function RoomScreen() { await sendFile(asset.uri, asset.name, asset.mimeType ?? getMimetypeFromFilename(asset.name)); } catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { setUploading(false); } + } finally { + setUploading(false); + } }; const handleForward = useCallback((msg: SimpleMessage) => { @@ -242,28 +306,37 @@ export default function RoomScreen() { setForwardSearch(''); }, []); - const handleForwardToRoom = useCallback(async (targetRoom: SimpleRoom) => { - if (!forwardingMessage) return; - try { - await forwardMessage(forwardingMessage.id, targetRoom.id); - setForwardingMessage(null); - } catch (err) { - Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error'); - } - }, [forwardingMessage, forwardMessage]); + const handleForwardToRoom = useCallback( + async (targetRoom: SimpleRoom) => { + if (!forwardingMessage) return; + try { + await forwardMessage(forwardingMessage.id, targetRoom.id); + setForwardingMessage(null); + } catch (err) { + Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error'); + } + }, + [forwardingMessage, forwardMessage] + ); const handleEdit = useCallback((msg: SimpleMessage) => { setReplyTo(null); setEditingMessage(msg); }, []); - const handleSend = useCallback(async (body: string, replyToEventId?: string) => { - await sendMessage(body, replyToEventId); - }, [sendMessage]); + const handleSend = useCallback( + async (body: string, replyToEventId?: string) => { + await sendMessage(body, replyToEventId); + }, + [sendMessage] + ); - const handleEditSave = useCallback(async (eventId: string, newBody: string) => { - await editMessage(eventId, newBody); - }, [editMessage]); + const handleEditSave = useCallback( + async (eventId: string, newBody: string) => { + await editMessage(eventId, newBody); + }, + [editMessage] + ); const renderItem = ({ item, index }: { item: ListItem; index: number }) => { if (item.type === 'date') return ; @@ -273,7 +346,10 @@ export default function RoomScreen() { { setEditingMessage(null); setReplyTo(msg); }} + onReply={(msg) => { + setEditingMessage(null); + setReplyTo(msg); + }} onEdit={handleEdit} onReact={sendReaction} onDelete={redactMessage} @@ -288,7 +364,7 @@ export default function RoomScreen() { {/* Header */} - router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}> + router.back()} className="p-1 active:opacity-50"> @@ -299,14 +375,16 @@ export default function RoomScreen() { {room?.isEncrypted && } {room?.topic ? ( - {room.topic} + + {room.topic} + ) : room?.memberCount != null ? ( {room.memberCount} member{room.memberCount !== 1 ? 's' : ''} ) : null} - `p-1 ${pressed ? 'opacity-50' : ''}`}> + @@ -321,9 +399,9 @@ export default function RoomScreen() { item.type === 'message' ? item.data.id : item.key} + keyExtractor={(item) => (item.type === 'message' ? item.data.id : item.key)} renderItem={renderItem} - contentContainerClassName="px-0 py-2" + contentContainerStyle={{ paddingHorizontal: 0, paddingVertical: 8 }} onEndReached={handleLoadMore} onEndReachedThreshold={0.15} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} @@ -342,9 +420,14 @@ export default function RoomScreen() { { setUploading(true); - try { await sendVoice(uri, durationMs); } - catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); } - finally { setUploading(false); setShowVoiceRecorder(false); } + try { + await sendVoice(uri, durationMs); + } catch (err) { + Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); + } finally { + setUploading(false); + setShowVoiceRecorder(false); + } }} onCancel={() => setShowVoiceRecorder(false)} /> @@ -363,22 +446,33 @@ export default function RoomScreen() { )} {/* Members modal */} - setShowMembers(false)}> + setShowMembers(false)} + > Members{room?.memberCount != null ? ` (${room.memberCount})` : ''} - setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}> + setShowMembers(false)} className="p-1 active:opacity-50"> - + {roomMembers.length === 0 ? ( - + + + ) : ( roomMembers.map((member) => ( - setShowMembers(false)} /> + setShowMembers(false)} + /> )) )} @@ -390,11 +484,16 @@ export default function RoomScreen() { setProfileUserId(null)} /> {/* Forward message modal */} - setForwardingMessage(null)}> + setForwardingMessage(null)} + > Forward to... - setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}> + setForwardingMessage(null)} className="p-1 active:opacity-50"> @@ -411,21 +510,29 @@ export default function RoomScreen() { {forwardingMessage && ( Message: - {forwardingMessage.body} + + {forwardingMessage.body} + )} - + {rooms - .filter((r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase())) + .filter( + (r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase()) + ) .map((r) => ( handleForwardToRoom(r)} - className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`} + className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60" > {r.avatar ? ( - + ) : ( {r.name[0]?.toUpperCase() ?? '?'} @@ -433,8 +540,12 @@ export default function RoomScreen() { )} - {r.name} - {r.isDirect && Direct message} + + {r.name} + + {r.isDirect && ( + Direct message + )} ))} diff --git a/apps/matrix/apps/mobile/app/room/new.tsx b/apps/matrix/apps/mobile/app/room/new.tsx index 20982d85f..c2040a9f0 100644 --- a/apps/matrix/apps/mobile/app/room/new.tsx +++ b/apps/matrix/apps/mobile/app/room/new.tsx @@ -83,10 +83,7 @@ export default function NewRoomScreen() { {/* Header */} - router.back()} - className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`} - > + router.back()} className="p-1 active:opacity-50"> New conversation @@ -97,7 +94,7 @@ export default function NewRoomScreen() { className="flex-1" > {/* Mode toggle */} @@ -195,9 +192,7 @@ export default function NewRoomScreen() { - `bg-primary rounded-xl py-4 items-center ${pressed || loading ? 'opacity-70' : ''}` - } + className="bg-primary rounded-xl py-4 items-center active:opacity-70" > {loading ? ( diff --git a/apps/matrix/apps/mobile/app/room/settings.tsx b/apps/matrix/apps/mobile/app/room/settings.tsx index 631fb9a5a..afd30e9e2 100644 --- a/apps/matrix/apps/mobile/app/room/settings.tsx +++ b/apps/matrix/apps/mobile/app/room/settings.tsx @@ -1,5 +1,13 @@ import { useState, useEffect } from 'react'; -import { View, Text, TextInput, Pressable, ScrollView, Alert, ActivityIndicator } from 'react-native'; +import { + View, + Text, + TextInput, + Pressable, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { ArrowLeft, Camera } from 'phosphor-react-native'; @@ -46,7 +54,9 @@ export default function RoomSettingsScreen() { const uploaded = await uploadMedia(client, asset.uri, filename, 'image/jpeg'); setNewAvatarMxc(uploaded.mxcUrl); setAvatarUri( - credentials ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) : asset.uri, + credentials + ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) + : asset.uri ); } catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); @@ -80,36 +90,34 @@ export default function RoomSettingsScreen() { }; const hasChanges = - name.trim() !== room?.name || - topic.trim() !== (room?.topic ?? '') || - newAvatarMxc !== null; + name.trim() !== room?.name || topic.trim() !== (room?.topic ?? '') || newAvatarMxc !== null; return ( {/* Header */} - router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}> + router.back()} className="p-1 active:opacity-50"> Room Settings - `px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} ${pressed ? 'opacity-60' : ''}` - } + className={`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`} > {saving ? ( ) : ( - + Save )} - + {/* Avatar */} @@ -117,7 +125,11 @@ export default function RoomSettingsScreen() { {uploadingAvatar ? ( ) : avatarUri ? ( - + ) : ( {room?.name?.[0]?.toUpperCase() ?? '#'} @@ -165,7 +177,9 @@ export default function RoomSettingsScreen() { Room ID - {id} + + {id} + diff --git a/apps/matrix/apps/mobile/app/search.tsx b/apps/matrix/apps/mobile/app/search.tsx index 3a05f90ed..483931045 100644 --- a/apps/matrix/apps/mobile/app/search.tsx +++ b/apps/matrix/apps/mobile/app/search.tsx @@ -1,13 +1,5 @@ import { useState, useCallback } from 'react'; -import { - View, - Text, - TextInput, - FlatList, - Pressable, - ActivityIndicator, - Alert, -} from 'react-native'; +import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { ArrowLeft, MagnifyingGlass, Lock, Users } from 'phosphor-react-native'; @@ -57,7 +49,7 @@ export default function SearchScreen() { setLoading(false); } }, - [client, credentials], + [client, credentials] ); const handleSearch = (text: string) => { @@ -98,7 +90,11 @@ export default function SearchScreen() { {/* Avatar */} {item.avatar_url ? ( - + ) : ( {initial} )} @@ -110,9 +106,7 @@ export default function SearchScreen() { {name} - {item.join_rule === 'public' ? null : ( - - )} + {item.join_rule === 'public' ? null : } {item.topic && ( @@ -129,9 +123,7 @@ export default function SearchScreen() { handleJoin(item)} disabled={isJoining} - className={({ pressed }) => - `bg-primary rounded-lg px-3 py-1.5 shrink-0 ${pressed || isJoining ? 'opacity-60' : ''}` - } + className="bg-primary rounded-lg px-3 py-1.5 shrink-0 active:opacity-60" > {isJoining ? ( @@ -147,10 +139,7 @@ export default function SearchScreen() { {/* Header */} - router.back()} - className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`} - > + router.back()} className="p-1 active:opacity-50"> Explore rooms diff --git a/apps/matrix/apps/mobile/metro.config.js b/apps/matrix/apps/mobile/metro.config.js index 84243860e..d285173b2 100644 --- a/apps/matrix/apps/mobile/metro.config.js +++ b/apps/matrix/apps/mobile/metro.config.js @@ -1,5 +1,9 @@ const { getDefaultConfig } = require('expo/metro-config'); const { withNativeWind } = require('nativewind/metro'); +const path = require('path'); + +// Monorepo root where hoisted node_modules live +const monorepoRoot = path.resolve(__dirname, '../../../..'); const config = getDefaultConfig(__dirname); @@ -11,12 +15,37 @@ config.resolver.extraNodeModules = { stream: require.resolve('stream-browserify'), }; -// Block matrix-sdk-crypto-wasm (uses import.meta, not compatible with Hermes) +// In pnpm monorepos with node-linker=hoisted, pnpm may place a different version of +// react-native-css-interop in the app's local node_modules vs the monorepo root. +// This causes module duplication in the Metro bundle: the transformer's injectData() +// writes styles to one module instance while the JSX runtime's getStyle() reads from +// another (empty) instance, resulting in no styles being applied. +// +// Fix: intercept react-native-css-interop imports and resolve them from the monorepo +// root node_modules, bypassing any local copy. config.resolver.resolveRequest = (context, moduleName, platform) => { + // Block matrix-sdk-crypto-wasm (uses import.meta, not compatible with Hermes) if (moduleName === '@matrix-org/matrix-sdk-crypto-wasm') { return { type: 'empty' }; } + + // Deduplicate react-native-css-interop by resolving from monorepo root + if ( + moduleName === 'react-native-css-interop' || + moduleName.startsWith('react-native-css-interop/') + ) { + return context.resolveRequest({ ...context, originDir: monorepoRoot }, moduleName, platform); + } + return context.resolveRequest(context, moduleName, platform); }; -module.exports = withNativeWind(config, { input: './global.css' }); +// In a pnpm monorepo with node-linker=hoisted, the virtual module system used by +// react-native-css-interop can fail because node_modules are at the monorepo root, +// not inside the app directory. Using forceWriteFileSystem bypasses virtual modules +// and writes CSS data directly to the cache files on disk, which Metro then reads +// and the transformer wraps with injectData(). +module.exports = withNativeWind(config, { + input: './global.css', + forceWriteFileSystem: true, +}); diff --git a/apps/matrix/apps/mobile/package.json b/apps/matrix/apps/mobile/package.json index 7e9e0343c..c9a251046 100644 --- a/apps/matrix/apps/mobile/package.json +++ b/apps/matrix/apps/mobile/package.json @@ -38,12 +38,12 @@ "expo-system-ui": "~55.0.9", "expo-web-browser": "~55.0.9", "matrix-js-sdk": "^37.1.0", - "nativewind": "~4.2.2", + "nativewind": "~4.2.3", "phosphor-react-native": "^2.3.0", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", - "react-native-css-interop": "0.2.2", + "react-native-css-interop": "0.2.3", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "~4.2.1", "react-native-safe-area-context": "~5.6.2", diff --git a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx index 829df5335..a44885ce2 100644 --- a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx +++ b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx @@ -38,18 +38,14 @@ export default function ImageViewer({ uri, onClose }: Props) { - `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed ? 'opacity-60' : ''}` - } + className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60" > - `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed || saving ? 'opacity-60' : ''}` - } + className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60" > diff --git a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx index 7f5d506cf..d7fbfe09a 100644 --- a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx +++ b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx @@ -66,7 +66,7 @@ function AvatarCircle({ ); if (!onPress) return inner; return ( - `${pressed ? 'opacity-60' : ''}`}> + {inner} ); @@ -113,17 +113,14 @@ function ReactionDetailsModal({ Reactions - `p-1 ${pressed ? 'opacity-50' : ''}`} - > + Done {reactions.map((r) => ( ))} - + {selected?.users.map((userId) => ( diff --git a/apps/matrix/apps/mobile/src/components/MessageInput.tsx b/apps/matrix/apps/mobile/src/components/MessageInput.tsx index 29bffa5a9..2d6cb023d 100644 --- a/apps/matrix/apps/mobile/src/components/MessageInput.tsx +++ b/apps/matrix/apps/mobile/src/components/MessageInput.tsx @@ -76,9 +76,13 @@ export default function MessageInput({ {/* Context banner: Reply or Edit */} {(replyTo || isEditing) && ( - + - + {isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`} @@ -87,7 +91,7 @@ export default function MessageInput({ `p-1 ${pressed ? 'opacity-50' : ''}`} + className="p-1 active:opacity-50" > @@ -99,9 +103,7 @@ export default function MessageInput({ {onAttach && !isEditing && ( - `w-10 h-10 items-center justify-center rounded-full ${pressed ? 'opacity-50' : ''}` - } + className="w-10 h-10 items-center justify-center rounded-full active:opacity-50" > @@ -126,9 +128,7 @@ export default function MessageInput({ {showMic ? ( - `w-10 h-10 rounded-full items-center justify-center bg-surface border border-border ${pressed ? 'opacity-60' : ''}` - } + className="w-10 h-10 rounded-full items-center justify-center bg-surface border border-border active:opacity-60" > @@ -136,13 +136,13 @@ export default function MessageInput({ - `w-10 h-10 rounded-full items-center justify-center ${ - canSend - ? isEditing ? 'bg-yellow-500' : 'bg-primary' - : 'bg-surface border border-border' - } ${pressed ? 'opacity-60' : ''}` - } + className={`w-10 h-10 rounded-full items-center justify-center ${ + canSend + ? isEditing + ? 'bg-yellow-500' + : 'bg-primary' + : 'bg-surface border border-border' + } active:opacity-60`} > {isEditing ? ( diff --git a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx index b03f1cc9e..df065d175 100644 --- a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx +++ b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx @@ -38,9 +38,7 @@ export default function RoomListItem({ room, onPress }: Props) { return ( - `flex-row items-center px-4 py-3 gap-3 ${pressed ? 'bg-surface/60' : ''}` - } + className="flex-row items-center px-4 py-3 gap-3 active:bg-surface/60" > {/* Avatar */} @@ -80,8 +78,8 @@ export default function RoomListItem({ room, onPress }: Props) { > {room.lastMessage ? (room.lastMessageSender && !room.isDirect - ? `${room.lastMessageSender.split(':')[0].slice(1)}: ` - : '') + room.lastMessage + ? `${room.lastMessageSender.split(':')[0].slice(1)}: ` + : '') + room.lastMessage : room.isEncrypted ? '🔒 Encrypted' : 'No messages'} @@ -95,7 +93,11 @@ export default function RoomListItem({ room, onPress }: Props) { }`} > - {hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount} + {hasHighlight + ? room.highlightCount + : room.unreadCount > 99 + ? '99+' + : room.unreadCount} )} diff --git a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx index 126e0e767..837a95f76 100644 --- a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx +++ b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx @@ -29,14 +29,15 @@ export default function UserProfileModal({ userId, onClose }: Props) { setLoading(true); setProfile(null); - client.getProfileInfo(userId) + client + .getProfileInfo(userId) .then((info) => { const rawAvatar = info.avatar_url ?? null; setProfile({ userId, displayName: info.displayname ?? userId.split(':')[0].slice(1), avatarUrl: rawAvatar - ? resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined + ? (resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined) : undefined, }); }) @@ -50,9 +51,7 @@ export default function UserProfileModal({ userId, onClose }: Props) { }, [userId]); // Find an existing DM room with this user - const existingDM = userId - ? rooms.find((r) => r.isDirect && r.dmUserId === userId) - : null; + const existingDM = userId ? rooms.find((r) => r.isDirect && r.dmUserId === userId) : null; const handleStartDM = async () => { if (!client || !userId || !credentials) return; @@ -81,12 +80,7 @@ export default function UserProfileModal({ userId, onClose }: Props) { const initial = profile?.displayName[0]?.toUpperCase() ?? '?'; return ( - + e.stopPropagation()}> @@ -98,12 +92,20 @@ export default function UserProfileModal({ userId, onClose }: Props) { {/* Close */} - `p-1 ${pressed ? 'opacity-50' : ''}`}> + - + {loading ? ( ) : profile ? ( @@ -111,7 +113,11 @@ export default function UserProfileModal({ userId, onClose }: Props) { {/* Avatar */} {profile.avatarUrl ? ( - + ) : ( {initial} )} @@ -119,17 +125,19 @@ export default function UserProfileModal({ userId, onClose }: Props) { {/* Name */} - {profile.displayName} - {profile.userId} + + {profile.displayName} + + + {profile.userId} + {/* Actions */} {profile.userId !== credentials?.userId && ( - `flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 ${pressed ? 'opacity-70' : ''}` - } + className="flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 active:opacity-70" > diff --git a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx index 85dba87bb..945b8a7f4 100644 --- a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx +++ b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx @@ -47,9 +47,7 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) { - `w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}` - } + className={`w-8 h-8 rounded-full items-center justify-center active:opacity-60 ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`} > {status.isBuffering ? ( diff --git a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx index 511cf5ee8..6ed38c28a 100644 --- a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx +++ b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx @@ -94,9 +94,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) { {/* Discard */} - `w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}` - } + className="w-10 h-10 rounded-full bg-destructive/10 items-center justify-center active:opacity-60" > @@ -115,9 +113,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) { - `w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} ${pressed || sending ? 'opacity-60' : ''}` - } + className={`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`} > = 1 ? '#fff' : '#6b7280'} weight="fill" /> diff --git a/apps/mukke/CLAUDE.md b/apps/mukke/CLAUDE.md new file mode 100644 index 000000000..b586e5bbe --- /dev/null +++ b/apps/mukke/CLAUDE.md @@ -0,0 +1,60 @@ +# 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 diff --git a/apps/mukke/apps/mobile/.gitignore b/apps/mukke/apps/mobile/.gitignore new file mode 100644 index 000000000..5873d9abc --- /dev/null +++ b/apps/mukke/apps/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mukke/apps/mobile/app.json b/apps/mukke/apps/mobile/app.json new file mode 100644 index 000000000..ec396f149 --- /dev/null +++ b/apps/mukke/apps/mobile/app.json @@ -0,0 +1,53 @@ +{ + "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" + } + } + } +} diff --git a/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx b/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 000000000..b088e9b55 --- /dev/null +++ b/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,33 @@ +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 ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/(tabs)/index.tsx b/apps/mukke/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 000000000..6a099d7cf --- /dev/null +++ b/apps/mukke/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,72 @@ +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 ( + + ( + + {activeTab === 'songs' && ( + { + setSortField(field); + setSortDirection(dir); + }} + /> + )} + + + ), + }} + /> + + + + {activeTab === 'songs' && } + {activeTab === 'albums' && } + {activeTab === 'artists' && } + {activeTab === 'genres' && } + + ); +} diff --git a/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx b/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx new file mode 100644 index 000000000..04a7128a2 --- /dev/null +++ b/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx @@ -0,0 +1,70 @@ +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 ( + + ( + router.push('/playlist/new')} style={{ padding: 8 }}> + + + ), + }} + /> + + {playlists.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={({ item }) => ( + + + + } + onPress={() => router.push(`/playlist/${item.id}`)} + showChevron + /> + )} + /> + )} + + ); +} diff --git a/apps/mukke/apps/mobile/app/(tabs)/search.tsx b/apps/mukke/apps/mobile/app/(tabs)/search.tsx new file mode 100644 index 000000000..d932d9730 --- /dev/null +++ b/apps/mukke/apps/mobile/app/(tabs)/search.tsx @@ -0,0 +1,79 @@ +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([]); + 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 ( + + + + + + + + + {!hasSearched ? ( + + ) : results.length === 0 ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mukke/apps/mobile/app/(tabs)/settings.tsx b/apps/mukke/apps/mobile/app/(tabs)/settings.tsx new file mode 100644 index 000000000..d4d53a307 --- /dev/null +++ b/apps/mukke/apps/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,187 @@ +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 ( + + + + {/* Appearance */} + + Darstellung + + + + Dark Mode + + + + + Akzentfarbe + + {variants.map((v) => ( + setThemeVariant(v.key)} + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: v.color, + borderWidth: themeVariant === v.key ? 3 : 0, + borderColor: colors.text, + }} + /> + ))} + + + + + {/* Import */} + + Musik + + + + + Songs importieren + + + + {/* Storage */} + + Speicher + + + + + Songs + {songCount} + + + Musik + + {formatFileSize(storageInfo.musicSize)} + + + + Cover Art + + {formatFileSize(storageInfo.artworkSize)} + + + + + + {/* About */} + + Info + + + + + Version + 1.0.0 + + + Mukke + Offline Music Player + + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/+not-found.tsx b/apps/mukke/apps/mobile/app/+not-found.tsx new file mode 100644 index 000000000..bb486b5ef --- /dev/null +++ b/apps/mukke/apps/mobile/app/+not-found.tsx @@ -0,0 +1,16 @@ +import { Link, Stack } from 'expo-router'; +import { Text, View } from 'react-native'; + +export default function NotFoundScreen() { + return ( + <> + + + Diese Seite existiert nicht. + + Zur Bibliothek + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/_layout.tsx b/apps/mukke/apps/mobile/app/_layout.tsx new file mode 100644 index 000000000..1719ff3c1 --- /dev/null +++ b/apps/mukke/apps/mobile/app/_layout.tsx @@ -0,0 +1,78 @@ +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 ( + + + + {({ isDarkMode }) => ( + + + + + + + + + + + + + + )} + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/album/[id].tsx b/apps/mukke/apps/mobile/app/album/[id].tsx new file mode 100644 index 000000000..23e7ebed7 --- /dev/null +++ b/apps/mukke/apps/mobile/app/album/[id].tsx @@ -0,0 +1,53 @@ +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([]); + + 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 ( + + {/* Album Header */} + + + + {albumName} + + + {artist} + {year ? ` · ${year}` : ''} · {songs.length} Songs + + + + playSong(song, songs, index)} + emptyTitle="Keine Songs" + /> + + ); +} diff --git a/apps/mukke/apps/mobile/app/artist/[id].tsx b/apps/mukke/apps/mobile/app/artist/[id].tsx new file mode 100644 index 000000000..5d56e0abc --- /dev/null +++ b/apps/mukke/apps/mobile/app/artist/[id].tsx @@ -0,0 +1,47 @@ +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([]); + + useEffect(() => { + if (artistName) { + getSongsByArtist(artistName).then(setSongs); + } + }, [artistName]); + + const albumCount = new Set(songs.map((s) => s.album).filter(Boolean)).size; + + return ( + + {/* Artist Header */} + + + + {artistName} + + + {songs.length} Songs · {albumCount} Alben + + + + playSong(song, songs, index)} + emptyTitle="Keine Songs" + /> + + ); +} diff --git a/apps/mukke/apps/mobile/app/player.tsx b/apps/mukke/apps/mobile/app/player.tsx new file mode 100644 index 000000000..06cd21c76 --- /dev/null +++ b/apps/mukke/apps/mobile/app/player.tsx @@ -0,0 +1,102 @@ +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 ( + + Kein Song wird abgespielt + + ); + } + + return ( + + {/* Header */} + + router.back()} style={{ padding: 4 }}> + + + + WIRD ABGESPIELT + + router.push('/queue')} style={{ padding: 4 }}> + + + + + {/* Artwork */} + + + + + {/* Song Info */} + + + + + {currentSong.title} + + + {currentSong.artist || 'Unbekannt'} + + + toggleFavorite(currentSong.id)} style={{ padding: 8 }}> + + + + + + {/* Progress */} + + + {/* Transport */} + + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/playlist/[id].tsx b/apps/mukke/apps/mobile/app/playlist/[id].tsx new file mode 100644 index 000000000..f535999c4 --- /dev/null +++ b/apps/mukke/apps/mobile/app/playlist/[id].tsx @@ -0,0 +1,118 @@ +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(null); + const [songs, setSongs] = useState([]); + 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 ( + + ( + setShowPicker(true)} style={{ padding: 8 }}> + + + ), + }} + /> + + {playlist && ( + + + + + + {playlist.name} + + {playlist.description && ( + + {playlist.description} + + )} + + {songs.length} Songs + + + )} + + playSong(song, songs, index)} + emptyTitle="Playlist ist leer" + emptyMessage="Füge Songs über den + Button hinzu." + /> + + setShowPicker(false)} + onSelect={handleAddSongs} + excludeIds={songs.map((s) => s.id)} + /> + + ); +} diff --git a/apps/mukke/apps/mobile/app/playlist/new.tsx b/apps/mukke/apps/mobile/app/playlist/new.tsx new file mode 100644 index 000000000..7aa3279cc --- /dev/null +++ b/apps/mukke/apps/mobile/app/playlist/new.tsx @@ -0,0 +1,84 @@ +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 ( + + Name + + + + Beschreibung (optional) + + + + + + Erstellen + + + + ); +} diff --git a/apps/mukke/apps/mobile/app/queue.tsx b/apps/mukke/apps/mobile/app/queue.tsx new file mode 100644 index 000000000..6ae972dc9 --- /dev/null +++ b/apps/mukke/apps/mobile/app/queue.tsx @@ -0,0 +1,77 @@ +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 ( + + {currentSong && ( + + + AKTUELLER SONG + + + + + + {currentSong.title} + + + {currentSong.artist || 'Unbekannt'} + + + + + )} + + + ALS NÄCHSTES + + + `${item.id}-${index}`} + contentContainerStyle={{ paddingBottom: 40 }} + renderItem={({ item, index }) => ( + } + onPress={() => playSong(item, queue, currentIndex + 1 + index)} + /> + )} + /> + + ); +} diff --git a/apps/mukke/apps/mobile/assets/adaptive-icon.png b/apps/mukke/apps/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..35c8a1c1f3852c78ee16339b934ba2dfa5145391 GIT binary patch literal 4555 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&w@K)Q9>#R~Q%sCwaO!hE&{od)<(ifq{c% zqj{^=pYsoxXN4Tho&FN2Ze9maPXbUq12Ygy0Pz7JW?%zi10Zf-1QI+zoB+gdCAeh{ zfRtfqCem2AW(*7AGT3d#urLAW3b<>rYaVQFA|d!e9%^6=07eb11Bzrv0bNFdo3O`B z0?-gVkvGWPM8LHep<%$Fg)3Vf0C^m1TE;DdJ)jamCXo#R~Q%sCwaO!hE&{od)<(ifq{c% zqj{^=pYsoxXN4Tho&FN2Ze9maPXbUq12Ygy0Pz7JW?%zi10Zf-1QI+zoB+gdCAeh{ zfRtfqCem2AW(*7AGT3d#urLAW3b<>rYaVQFA|d!e9%^6=07eb11Bzrv0bNFdo3O`B z0?-gVkvGWPM8LHep<%$Fg)3Vf0C^m1TE;DdJ)jamCXo8YC`k+|K!DDF<9>+y^e0>j(;t@RREoe?@$PmH%PCuQv@3>?^RjzeRldv!yZHY{y zuvgVtRW+s4nK6~mYxbsW6HO#xw1LSFKX-d!HC5l;LSZ)3oogI~Vfbq`2!g$9 zD2*RMX|44w41ee5gWzcGqXxxA&?RDs z;H!#`f{P@0j2Ei1tz)uxfVsOkkf{=I*Ap{j)gDu!cRvJlcp zG)a6h)(~vL^l=aom$XszAd%cqFe%X_8q(TwvdA0JQ5ei)h!{U&MG6WgC7Mk7?)i{0 z`KG&8g|uEZy#IN*(ZAg(P_8+nkMVxo=z658-CG%(>4D!NS)G$)j%V()os8FF*cG+o(*|%I(+n<{wj_J`n%_ literal 0 HcmV?d00001 diff --git a/apps/mukke/apps/mobile/babel.config.js b/apps/mukke/apps/mobile/babel.config.js new file mode 100644 index 000000000..d9beb89e8 --- /dev/null +++ b/apps/mukke/apps/mobile/babel.config.js @@ -0,0 +1,10 @@ +module.exports = function (api) { + api.cache(true); + const plugins = []; + + return { + presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + + plugins, + }; +}; diff --git a/apps/mukke/apps/mobile/components/AlbumGrid.tsx b/apps/mukke/apps/mobile/components/AlbumGrid.tsx new file mode 100644 index 000000000..16acad16c --- /dev/null +++ b/apps/mukke/apps/mobile/components/AlbumGrid.tsx @@ -0,0 +1,75 @@ +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 ( + + ); + } + + return ( + item.name} + numColumns={2} + contentContainerStyle={{ padding: 12, paddingBottom: 100 }} + columnWrapperStyle={{ gap: 12 }} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + router.push(`/album/${encodeURIComponent(item.name)}`)} + style={{ width: itemSize }} + > + {item.coverArtPath ? ( + + ) : ( + + + + )} + + {item.name} + + + {item.artist || 'Unbekannt'} · {item.songCount} Songs + + + )} + /> + ); +} diff --git a/apps/mukke/apps/mobile/components/ArtistList.tsx b/apps/mukke/apps/mobile/components/ArtistList.tsx new file mode 100644 index 000000000..324065014 --- /dev/null +++ b/apps/mukke/apps/mobile/components/ArtistList.tsx @@ -0,0 +1,43 @@ +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 ( + + ); + } + + return ( + item.name} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={({ item }) => ( + } + onPress={() => router.push(`/artist/${encodeURIComponent(item.name)}`)} + showChevron + /> + )} + /> + ); +} diff --git a/apps/mukke/apps/mobile/components/Artwork.tsx b/apps/mukke/apps/mobile/components/Artwork.tsx new file mode 100644 index 000000000..28db3282a --- /dev/null +++ b/apps/mukke/apps/mobile/components/Artwork.tsx @@ -0,0 +1,42 @@ +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 ( + + ); + } + + return ( + + + + ); +} diff --git a/apps/mukke/apps/mobile/components/Button.tsx b/apps/mukke/apps/mobile/components/Button.tsx new file mode 100644 index 000000000..1224076da --- /dev/null +++ b/apps/mukke/apps/mobile/components/Button.tsx @@ -0,0 +1,45 @@ +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 ( + + {loading ? ( + + ) : ( + {title} + )} + + ); +} diff --git a/apps/mukke/apps/mobile/components/EmptyState.tsx b/apps/mukke/apps/mobile/components/EmptyState.tsx new file mode 100644 index 000000000..ba03907d8 --- /dev/null +++ b/apps/mukke/apps/mobile/components/EmptyState.tsx @@ -0,0 +1,43 @@ +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 ( + + + + {title} + + {message && ( + + {message} + + )} + + ); +} diff --git a/apps/mukke/apps/mobile/components/GenreList.tsx b/apps/mukke/apps/mobile/components/GenreList.tsx new file mode 100644 index 000000000..cfb5de52d --- /dev/null +++ b/apps/mukke/apps/mobile/components/GenreList.tsx @@ -0,0 +1,65 @@ +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 ( + + ); + } + + const handlePress = async (genre: Genre) => { + const songs = await getSongsByGenre(genre.name); + if (songs.length > 0) { + playSong(songs[0], songs, 0); + } + }; + + return ( + item.name} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={({ item }) => ( + + + + } + onPress={() => handlePress(item)} + /> + )} + /> + ); +} diff --git a/apps/mukke/apps/mobile/components/ImportButton.tsx b/apps/mukke/apps/mobile/components/ImportButton.tsx new file mode 100644 index 000000000..3202c5b9f --- /dev/null +++ b/apps/mukke/apps/mobile/components/ImportButton.tsx @@ -0,0 +1,36 @@ +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 ( + + + + ); +} diff --git a/apps/mukke/apps/mobile/components/ListItem.tsx b/apps/mukke/apps/mobile/components/ListItem.tsx new file mode 100644 index 000000000..d1eabaa91 --- /dev/null +++ b/apps/mukke/apps/mobile/components/ListItem.tsx @@ -0,0 +1,66 @@ +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 ( + ({ + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: pressed ? colors.backgroundTertiary : 'transparent', + })} + > + {left && {left}} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {trailing && ( + {trailing} + )} + {showChevron && ( + + )} + + ); +} diff --git a/apps/mukke/apps/mobile/components/MiniPlayer.tsx b/apps/mukke/apps/mobile/components/MiniPlayer.tsx new file mode 100644 index 000000000..e31aa730d --- /dev/null +++ b/apps/mukke/apps/mobile/components/MiniPlayer.tsx @@ -0,0 +1,77 @@ +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 ( + router.push('/player')} + style={{ + position: 'absolute', + bottom: 49, + left: 0, + right: 0, + backgroundColor: colors.card, + borderTopWidth: 0.5, + borderTopColor: colors.border, + }} + > + {/* Progress indicator */} + + + + + + + + + + {currentSong.title} + + + {currentSong.artist || 'Unbekannt'} + + + + + + + + + + + + + ); +} diff --git a/apps/mukke/apps/mobile/components/ProgressBar.tsx b/apps/mukke/apps/mobile/components/ProgressBar.tsx new file mode 100644 index 000000000..ecba85bdc --- /dev/null +++ b/apps/mukke/apps/mobile/components/ProgressBar.tsx @@ -0,0 +1,37 @@ +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 ( + + 0 ? position / duration : 0} + onSlidingComplete={(value) => onSeek(value * duration)} + minimumValue={0} + maximumValue={1} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.backgroundTertiary} + thumbTintColor={colors.primary} + /> + + + {formatDuration(position)} + + + -{formatDuration(Math.max(0, duration - position))} + + + + ); +} diff --git a/apps/mukke/apps/mobile/components/SegmentedControl.tsx b/apps/mukke/apps/mobile/components/SegmentedControl.tsx new file mode 100644 index 000000000..50eadf3db --- /dev/null +++ b/apps/mukke/apps/mobile/components/SegmentedControl.tsx @@ -0,0 +1,57 @@ +import { Pressable, View, Text } from 'react-native'; + +import { useTheme } from '~/utils/themeContext'; + +interface SegmentedControlProps { + segments: { key: T; label: string }[]; + selected: T; + onSelect: (key: T) => void; +} + +export function SegmentedControl({ + segments, + selected, + onSelect, +}: SegmentedControlProps) { + const { colors } = useTheme(); + + return ( + + {segments.map((seg) => { + const isActive = seg.key === selected; + return ( + onSelect(seg.key)} + style={{ + flex: 1, + paddingVertical: 8, + borderRadius: 6, + backgroundColor: isActive ? colors.card : 'transparent', + alignItems: 'center', + }} + > + + {seg.label} + + + ); + })} + + ); +} diff --git a/apps/mukke/apps/mobile/components/SongList.tsx b/apps/mukke/apps/mobile/components/SongList.tsx new file mode 100644 index 000000000..a459853cd --- /dev/null +++ b/apps/mukke/apps/mobile/components/SongList.tsx @@ -0,0 +1,54 @@ +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 ; + } + + return ( + item.id} + renderItem={({ item, index }) => ( + } + onPress={() => handlePress(item, index)} + /> + )} + contentContainerStyle={{ paddingBottom: 100 }} + /> + ); +} diff --git a/apps/mukke/apps/mobile/components/SongPicker.tsx b/apps/mukke/apps/mobile/components/SongPicker.tsx new file mode 100644 index 000000000..88620afb1 --- /dev/null +++ b/apps/mukke/apps/mobile/components/SongPicker.tsx @@ -0,0 +1,110 @@ +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([]); + const [selected, setSelected] = useState>(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 ( + + + + + Abbrechen + + + Songs auswählen + + + + Fertig ({selected.size}) + + + + + item.id} + renderItem={({ item }) => { + const isSelected = selected.has(item.id); + return ( + toggleSelection(item.id)} + style={{ + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + }} + > + + + + + {item.title} + + + {item.artist || 'Unbekannt'} + + + + ); + }} + /> + + + ); +} diff --git a/apps/mukke/apps/mobile/components/SortMenu.tsx b/apps/mukke/apps/mobile/components/SortMenu.tsx new file mode 100644 index 000000000..a86f97a30 --- /dev/null +++ b/apps/mukke/apps/mobile/components/SortMenu.tsx @@ -0,0 +1,95 @@ +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 ( + <> + setVisible(true)} style={{ padding: 8 }}> + + + + + setVisible(false)} + style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }} + > + + + Sortieren + + {SORT_OPTIONS.map((opt) => { + const isActive = opt.field === currentField; + return ( + { + 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, + }} + > + + {opt.label} + + {isActive && ( + + )} + + ); + })} + + + + + ); +} diff --git a/apps/mukke/apps/mobile/components/ThemeWrapper.tsx b/apps/mukke/apps/mobile/components/ThemeWrapper.tsx new file mode 100644 index 000000000..7c603ad10 --- /dev/null +++ b/apps/mukke/apps/mobile/components/ThemeWrapper.tsx @@ -0,0 +1,20 @@ +import { View } from 'react-native'; + +import { useTheme } from '../utils/themeContext'; + +type ThemeWrapperProps = { + children: React.ReactNode; + className?: string; +}; + +export const ThemeWrapper: React.FC = ({ children, className = '' }) => { + const { isDarkMode } = useTheme(); + + return ( + + {children} + + ); +}; diff --git a/apps/mukke/apps/mobile/components/TransportControls.tsx b/apps/mukke/apps/mobile/components/TransportControls.tsx new file mode 100644 index 000000000..c74db9864 --- /dev/null +++ b/apps/mukke/apps/mobile/components/TransportControls.tsx @@ -0,0 +1,104 @@ +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 ( + + {size === 'large' && ( + + + + )} + + + + + + + + + + + + + + {size === 'large' && ( + + + {repeatMode === 'one' && ( + + + + )} + + )} + + ); +} diff --git a/apps/mukke/apps/mobile/contexts/AudioContext.tsx b/apps/mukke/apps/mobile/contexts/AudioContext.tsx new file mode 100644 index 000000000..542811dc1 --- /dev/null +++ b/apps/mukke/apps/mobile/contexts/AudioContext.tsx @@ -0,0 +1,140 @@ +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({ + 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(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 ( + + {children} + + ); +}; diff --git a/apps/mukke/apps/mobile/eas.json b/apps/mukke/apps/mobile/eas.json new file mode 100644 index 000000000..4e29cc0d1 --- /dev/null +++ b/apps/mukke/apps/mobile/eas.json @@ -0,0 +1,38 @@ +{ + "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": {} + } +} diff --git a/apps/mukke/apps/mobile/global.css b/apps/mukke/apps/mobile/global.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/apps/mukke/apps/mobile/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/mukke/apps/mobile/metro.config.js b/apps/mukke/apps/mobile/metro.config.js new file mode 100644 index 000000000..6bc2c9dd0 --- /dev/null +++ b/apps/mukke/apps/mobile/metro.config.js @@ -0,0 +1,25 @@ +// 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' }); diff --git a/apps/mukke/apps/mobile/nativewind-env.d.ts b/apps/mukke/apps/mobile/nativewind-env.d.ts new file mode 100644 index 000000000..958346287 --- /dev/null +++ b/apps/mukke/apps/mobile/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. diff --git a/apps/mukke/apps/mobile/package.json b/apps/mukke/apps/mobile/package.json new file mode 100644 index 000000000..c520df9a8 --- /dev/null +++ b/apps/mukke/apps/mobile/package.json @@ -0,0 +1,66 @@ +{ + "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 +} diff --git a/apps/mukke/apps/mobile/services/audioService.ts b/apps/mukke/apps/mobile/services/audioService.ts new file mode 100644 index 000000000..a23cb05ca --- /dev/null +++ b/apps/mukke/apps/mobile/services/audioService.ts @@ -0,0 +1,17 @@ +import { setAudioModeAsync } from 'expo-audio'; + +export { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; + +export async function configureAudioMode(): Promise { + 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')}`; +} diff --git a/apps/mukke/apps/mobile/services/database.ts b/apps/mukke/apps/mobile/services/database.ts new file mode 100644 index 000000000..1b763ec92 --- /dev/null +++ b/apps/mukke/apps/mobile/services/database.ts @@ -0,0 +1,67 @@ +import * as SQLite from 'expo-sqlite'; + +let db: SQLite.SQLiteDatabase | null = null; + +export async function getDatabase(): Promise { + if (db) return db; + db = await SQLite.openDatabaseAsync('mukke.db'); + await initializeDatabase(db); + return db; +} + +async function initializeDatabase(database: SQLite.SQLiteDatabase): Promise { + 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 { + if (db) { + await db.closeAsync(); + db = null; + } +} diff --git a/apps/mukke/apps/mobile/services/fileService.ts b/apps/mukke/apps/mobile/services/fileService.ts new file mode 100644 index 000000000..715c36d3b --- /dev/null +++ b/apps/mukke/apps/mobile/services/fileService.ts @@ -0,0 +1,102 @@ +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 { + 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 { + 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 { + 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`; +} diff --git a/apps/mukke/apps/mobile/services/importService.ts b/apps/mukke/apps/mobile/services/importService.ts new file mode 100644 index 000000000..ebc3f3d42 --- /dev/null +++ b/apps/mukke/apps/mobile/services/importService.ts @@ -0,0 +1,104 @@ +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 { + 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 { + // 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> | 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; +} diff --git a/apps/mukke/apps/mobile/services/libraryService.ts b/apps/mukke/apps/mobile/services/libraryService.ts new file mode 100644 index 000000000..0c6ac7292 --- /dev/null +++ b/apps/mukke/apps/mobile/services/libraryService.ts @@ -0,0 +1,176 @@ +import type { Album, Artist, Genre, Song } from '~/types'; + +import { getDatabase } from './database'; +import { deleteFile } from './fileService'; + +export async function insertSong(song: Song): Promise { + 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 { + 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( + `SELECT * FROM songs ORDER BY ${col} ${dir}` + ); + return rows.map((r) => ({ ...r, favorite: r.favorite === 1 })); +} + +export async function getSongById(id: string): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + 'SELECT * FROM songs WHERE id = ?', + id + ); + if (!row) return null; + return { ...row, favorite: row.favorite === 1 }; +} + +export async function deleteSong(id: string): Promise { + 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 { + 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 { + 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 { + const db = await getDatabase(); + await db.runAsync('UPDATE songs SET duration = ? WHERE id = ?', duration, id); +} + +export async function getAlbums(): Promise { + const db = await getDatabase(); + return db.getAllAsync(` + 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 { + const db = await getDatabase(); + const rows = await db.getAllAsync( + '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 { + const db = await getDatabase(); + return db.getAllAsync(` + 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 { + const db = await getDatabase(); + const rows = await db.getAllAsync( + '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 { + const db = await getDatabase(); + return db.getAllAsync(` + 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 { + const db = await getDatabase(); + const rows = await db.getAllAsync( + '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 { + const db = await getDatabase(); + const q = `%${query}%`; + const rows = await db.getAllAsync( + '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 { + const db = await getDatabase(); + const row = await db.getFirstAsync<{ count: number }>('SELECT COUNT(*) as count FROM songs'); + return row?.count ?? 0; +} diff --git a/apps/mukke/apps/mobile/services/playlistService.ts b/apps/mukke/apps/mobile/services/playlistService.ts new file mode 100644 index 000000000..f6ae210eb --- /dev/null +++ b/apps/mukke/apps/mobile/services/playlistService.ts @@ -0,0 +1,123 @@ +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 { + 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 { + const db = await getDatabase(); + return db.getAllAsync('SELECT * FROM playlists ORDER BY updatedAt DESC'); +} + +export async function getPlaylistById(id: string): Promise { + const db = await getDatabase(); + return db.getFirstAsync('SELECT * FROM playlists WHERE id = ?', id); +} + +export async function updatePlaylist( + id: string, + updates: { name?: string; description?: string } +): Promise { + 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 { + const db = await getDatabase(); + await db.runAsync('DELETE FROM playlists WHERE id = ?', id); +} + +export async function addSongToPlaylist(playlistId: string, songId: string): Promise { + 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 { + 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 { + const db = await getDatabase(); + const rows = await db.getAllAsync( + `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 { + 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; +} diff --git a/apps/mukke/apps/mobile/services/queueService.ts b/apps/mukke/apps/mobile/services/queueService.ts new file mode 100644 index 000000000..629c24922 --- /dev/null +++ b/apps/mukke/apps/mobile/services/queueService.ts @@ -0,0 +1,60 @@ +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; +} diff --git a/apps/mukke/apps/mobile/stores/libraryStore.ts b/apps/mukke/apps/mobile/stores/libraryStore.ts new file mode 100644 index 000000000..adc8e1e64 --- /dev/null +++ b/apps/mukke/apps/mobile/stores/libraryStore.ts @@ -0,0 +1,95 @@ +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; + loadAlbums: () => Promise; + loadArtists: () => Promise; + loadGenres: () => Promise; + loadAll: () => Promise; + toggleFavorite: (id: string) => Promise; +} + +export const useLibraryStore = create((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)), + })); + }, +})); diff --git a/apps/mukke/apps/mobile/stores/playerStore.ts b/apps/mukke/apps/mobile/stores/playerStore.ts new file mode 100644 index 000000000..3d8de9237 --- /dev/null +++ b/apps/mukke/apps/mobile/stores/playerStore.ts @@ -0,0 +1,134 @@ +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((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 }, + }); + }, +})); diff --git a/apps/mukke/apps/mobile/stores/playlistStore.ts b/apps/mukke/apps/mobile/stores/playlistStore.ts new file mode 100644 index 000000000..c0020bab6 --- /dev/null +++ b/apps/mukke/apps/mobile/stores/playlistStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand'; + +import type { Playlist } from '~/types'; +import * as playlistService from '~/services/playlistService'; + +interface PlaylistState { + playlists: Playlist[]; + isLoading: boolean; + + loadPlaylists: () => Promise; + createPlaylist: (name: string, description?: string) => Promise; + deletePlaylist: (id: string) => Promise; + updatePlaylist: (id: string, updates: { name?: string; description?: string }) => Promise; +} + +export const usePlaylistStore = create((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(); + }, +})); diff --git a/apps/mukke/apps/mobile/tailwind.config.js b/apps/mukke/apps/mobile/tailwind.config.js new file mode 100644 index 000000000..7c9a9f5e9 --- /dev/null +++ b/apps/mukke/apps/mobile/tailwind.config.js @@ -0,0 +1,24 @@ +/** @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: [], +}; diff --git a/apps/mukke/apps/mobile/tsconfig.json b/apps/mukke/apps/mobile/tsconfig.json new file mode 100644 index 000000000..de988058c --- /dev/null +++ b/apps/mukke/apps/mobile/tsconfig.json @@ -0,0 +1,12 @@ +{ + "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"] +} diff --git a/apps/mukke/apps/mobile/types/index.ts b/apps/mukke/apps/mobile/types/index.ts new file mode 100644 index 000000000..f2efaad14 --- /dev/null +++ b/apps/mukke/apps/mobile/types/index.ts @@ -0,0 +1,14 @@ +export type { + Song, + Album, + Artist, + Genre, + Playlist, + PlaylistSong, + RepeatMode, + ShuffleMode, + LibraryTab, + SortField, + SortDirection, + SortOption, +} from '@mukke/types'; diff --git a/apps/mukke/apps/mobile/utils/themeContext.tsx b/apps/mukke/apps/mobile/utils/themeContext.tsx new file mode 100644 index 000000000..84cb39a3a --- /dev/null +++ b/apps/mukke/apps/mobile/utils/themeContext.tsx @@ -0,0 +1,190 @@ +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({ + 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 = ({ children }) => { + const systemColorScheme = useColorScheme(); + const [isDarkMode, setIsDarkMode] = useState(false); + const [themeVariant, setThemeVariantState] = useState('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 ( + + {typeof children === 'function' ? children(themeContextValue) : children} + + ); +}; diff --git a/apps/mukke/package.json b/apps/mukke/package.json new file mode 100644 index 000000000..44052b0ab --- /dev/null +++ b/apps/mukke/package.json @@ -0,0 +1,9 @@ +{ + "name": "mukke", + "version": "1.0.0", + "private": true, + "description": "Mukke - Offline-first iOS Music Player", + "scripts": { + "dev": "pnpm run --filter=@mukke/* --parallel dev" + } +} diff --git a/apps/mukke/packages/mukke-types/package.json b/apps/mukke/packages/mukke-types/package.json new file mode 100644 index 000000000..36bd61b35 --- /dev/null +++ b/apps/mukke/packages/mukke-types/package.json @@ -0,0 +1,7 @@ +{ + "name": "@mukke/types", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "private": true +} diff --git a/apps/mukke/packages/mukke-types/src/index.ts b/apps/mukke/packages/mukke-types/src/index.ts new file mode 100644 index 000000000..ea55fd397 --- /dev/null +++ b/apps/mukke/packages/mukke-types/src/index.ts @@ -0,0 +1,69 @@ +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; +} diff --git a/apps/mukke/packages/mukke-types/tsconfig.json b/apps/mukke/packages/mukke-types/tsconfig.json new file mode 100644 index 000000000..64a30e04e --- /dev/null +++ b/apps/mukke/packages/mukke-types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}