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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-17 13:23:58 +01:00
parent 3dc6ec13a4
commit 6c91805b2f
80 changed files with 4035 additions and 241 deletions

View file

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

View file

@ -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() {
<Text className="text-foreground text-2xl font-bold">Direct Messages</Text>
<Pressable
onPress={() => 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"
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
@ -69,7 +67,7 @@ export default function DMsScreen() {
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerClassName="pb-4"
contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
dmInvites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">

View file

@ -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() {
<View className="flex-row gap-2">
<Pressable
onPress={() => 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"
>
<Compass size={18} color="#7c6bff" />
</Pressable>
<Pressable
onPress={() => 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"
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
@ -84,7 +80,7 @@ export default function ChatsScreen() {
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerClassName="pb-4"
contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
invites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">

View file

@ -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 (
<View className="mx-4 mb-3 bg-surface border border-border rounded-2xl overflow-hidden">
<View className="flex-row items-center gap-3 p-4">
{/* Avatar */}
<View className="w-12 h-12 rounded-full bg-background border border-border overflow-hidden items-center justify-center">
{room.avatar ? (
<Image source={{ uri: room.avatar }} style={{ width: 48, height: 48 }} contentFit="cover" />
<Image
source={{ uri: room.avatar }}
style={{ width: 48, height: 48 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-lg font-semibold">
{(room.name ?? '?')[0].toUpperCase()}
@ -30,17 +42,15 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
</Text>
)}
{room.inviter && (
<Text className="text-muted-foreground text-xs mt-0.5">
Invited by {room.inviter}
</Text>
<Text className="text-muted-foreground text-xs mt-0.5">Invited by {room.inviter}</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
<Text className="text-muted-foreground text-xs">
{room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
{room.isDirect
? 'Direct message'
: `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
</Text>
{room.isEncrypted && (
<Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>
)}
{room.isEncrypted && <Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>}
</View>
</View>
</View>
@ -49,15 +59,13 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
<View className="flex-row border-t border-border">
<Pressable
onPress={onDecline}
className={({ pressed }) =>
`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"
>
<Text className="text-destructive font-medium text-sm">Decline</Text>
</Pressable>
<Pressable
onPress={onAccept}
className={({ pressed }) => `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"
>
<Text className="text-white font-semibold text-sm">Accept</Text>
</Pressable>
@ -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={
<View className="items-center justify-center py-24">
<Text className="text-4xl mb-3"></Text>

View file

@ -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() {
<Text className="text-foreground text-2xl font-bold">Settings</Text>
</View>
<ScrollView className="flex-1" contentContainerClassName="p-4 gap-4">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 16 }}>
{/* Profile card */}
<View className="bg-surface rounded-2xl border border-border p-4 items-center gap-3">
{/* Avatar */}
<Pressable
onPress={handleChangeAvatar}
disabled={uploadingAvatar}
className="relative"
>
<Pressable onPress={handleChangeAvatar} disabled={uploadingAvatar} className="relative">
<ProfileAvatar
displayName={profileInfo.displayName}
avatarUrl={profileInfo.avatarUrl}
@ -148,18 +146,26 @@ export default function SettingsScreen() {
{/* Connection info */}
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
<View className="px-4 py-3 border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">Connection</Text>
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
Connection
</Text>
</View>
<View className="p-4 gap-3">
<View>
<Text className="text-muted-foreground text-xs">Homeserver</Text>
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>{homeserver || '—'}</Text>
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>
{homeserver || '—'}
</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-xs">Sync status</Text>
<View className="flex-row items-center gap-1.5">
<View className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`} />
<Text className="text-foreground text-sm capitalize">{syncState.toLowerCase()}</Text>
<View
className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`}
/>
<Text className="text-foreground text-sm capitalize">
{syncState.toLowerCase()}
</Text>
</View>
</View>
</View>
@ -189,16 +195,19 @@ export default function SettingsScreen() {
{/* Sign out */}
<Pressable
onPress={handleLogout}
className={({ pressed }) =>
`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"
>
<Text className="text-destructive font-semibold">Sign out</Text>
</Pressable>
</ScrollView>
{/* Edit display name modal */}
<Modal visible={editingName} transparent animationType="fade" onRequestClose={() => setEditingName(false)}>
<Modal
visible={editingName}
transparent
animationType="fade"
onRequestClose={() => setEditingName(false)}
>
<View className="flex-1 bg-black/60 items-center justify-center p-6">
<View className="bg-surface border border-border rounded-2xl p-5 w-full gap-4">
<View className="flex-row items-center justify-between">
@ -218,9 +227,7 @@ export default function SettingsScreen() {
<Pressable
onPress={handleSaveName}
disabled={savingName || !newDisplayName.trim()}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator color="#fff" />

View file

@ -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"
>
<ScrollView
contentContainerClassName="flex-grow justify-center p-6"
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: 24 }}
keyboardShouldPersistTaps="handled"
>
{/* Logo */}
@ -134,7 +134,10 @@ export default function LoginScreen() {
<TextInput
className="flex-1 bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={homeserver}
onChangeText={(v) => { setHomeserver(v); setServerOk(null); }}
onChangeText={(v) => {
setHomeserver(v);
setServerOk(null);
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@ -187,9 +190,7 @@ export default function LoginScreen() {
<Pressable
onPress={handleLogin}
disabled={loading || ssoLoading}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator color="#fff" />
@ -209,18 +210,12 @@ export default function LoginScreen() {
<Pressable
onPress={handleSSO}
disabled={loading || ssoLoading}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator color="#7c6bff" />
) : (
<Text className="text-foreground font-medium text-base">
Sign in with SSO
</Text>
<Text className="text-foreground font-medium text-base">Sign in with SSO</Text>
)}
</Pressable>
</View>

View file

@ -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
<>
<Pressable
onPress={() => 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"
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{member.avatarUrl ? (
<Image source={{ uri: member.avatarUrl }} style={{ width: 40, height: 40 }} contentFit="cover" />
<Image
source={{ uri: member.avatarUrl }}
style={{ width: 40, height: 40 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground font-semibold">
{member.displayName[0]?.toUpperCase() ?? '?'}
@ -78,7 +85,9 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">{member.displayName}</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{member.userId}</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{member.userId}
</Text>
</View>
{member.powerLevel >= 100 && (
<View className="bg-primary/20 rounded-full px-2 py-0.5">
@ -93,7 +102,10 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
</Pressable>
<UserProfileModal
userId={showProfile ? member.userId : null}
onClose={() => { 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 <DateSeparator timestamp={item.timestamp} />;
@ -273,7 +346,10 @@ export default function RoomScreen() {
<MessageBubble
message={item.data}
prevMessage={messages[msgIndex - 1] ?? null}
onReply={(msg) => { setEditingMessage(null); setReplyTo(msg); }}
onReply={(msg) => {
setEditingMessage(null);
setReplyTo(msg);
}}
onEdit={handleEdit}
onReact={sendReaction}
onDelete={redactMessage}
@ -288,7 +364,7 @@ export default function RoomScreen() {
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<View className="flex-1">
@ -299,14 +375,16 @@ export default function RoomScreen() {
{room?.isEncrypted && <Lock size={12} color="#22c55e" weight="fill" />}
</View>
{room?.topic ? (
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{room.topic}</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{room.topic}
</Text>
) : room?.memberCount != null ? (
<Text className="text-muted-foreground text-xs">
{room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
</Text>
) : null}
</View>
<Pressable onPress={handleRoomOptions} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={handleRoomOptions} className="p-1 active:opacity-50">
<DotsThreeVertical size={22} color="#6b7280" />
</Pressable>
</View>
@ -321,9 +399,9 @@ export default function RoomScreen() {
<FlatList
ref={listRef}
data={listItems}
keyExtractor={(item) => 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() {
<VoiceRecorder
onSend={async (uri, durationMs) => {
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 */}
<Modal visible={showMembers} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowMembers(false)}>
<Modal
visible={showMembers}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowMembers(false)}
>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">
Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
</Text>
<Pressable onPress={() => setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={() => setShowMembers(false)} className="p-1 active:opacity-50">
<X size={22} color="#6b7280" />
</Pressable>
</View>
<ScrollView contentContainerClassName="py-2">
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
{roomMembers.length === 0 ? (
<View className="items-center py-10"><ActivityIndicator color="#7c6bff" /></View>
<View className="items-center py-10">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
roomMembers.map((member) => (
<MemberRow key={member.userId} member={member} onClose={() => setShowMembers(false)} />
<MemberRow
key={member.userId}
member={member}
onClose={() => setShowMembers(false)}
/>
))
)}
</ScrollView>
@ -390,11 +484,16 @@ export default function RoomScreen() {
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
{/* Forward message modal */}
<Modal visible={!!forwardingMessage} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setForwardingMessage(null)}>
<Modal
visible={!!forwardingMessage}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setForwardingMessage(null)}
>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Forward to...</Text>
<Pressable onPress={() => setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={() => setForwardingMessage(null)} className="p-1 active:opacity-50">
<X size={22} color="#6b7280" />
</Pressable>
</View>
@ -411,21 +510,29 @@ export default function RoomScreen() {
{forwardingMessage && (
<View className="mx-4 mb-2 px-3 py-2 bg-surface border border-border rounded-xl">
<Text className="text-muted-foreground text-xs mb-0.5">Message:</Text>
<Text className="text-foreground text-sm" numberOfLines={2}>{forwardingMessage.body}</Text>
<Text className="text-foreground text-sm" numberOfLines={2}>
{forwardingMessage.body}
</Text>
</View>
)}
<ScrollView contentContainerClassName="py-1">
<ScrollView contentContainerStyle={{ paddingVertical: 4 }}>
{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) => (
<Pressable
key={r.id}
onPress={() => 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"
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{r.avatar ? (
<Image source={{ uri: r.avatar }} style={{ width: 40, height: 40 }} contentFit="cover" />
<Image
source={{ uri: r.avatar }}
style={{ width: 40, height: 40 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground font-semibold">
{r.name[0]?.toUpperCase() ?? '?'}
@ -433,8 +540,12 @@ export default function RoomScreen() {
)}
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>{r.name}</Text>
{r.isDirect && <Text className="text-muted-foreground text-xs">Direct message</Text>}
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>
{r.name}
</Text>
{r.isDirect && (
<Text className="text-muted-foreground text-xs">Direct message</Text>
)}
</View>
</Pressable>
))}

View file

@ -83,10 +83,7 @@ export default function NewRoomScreen() {
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable
onPress={() => router.back()}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">New conversation</Text>
@ -97,7 +94,7 @@ export default function NewRoomScreen() {
className="flex-1"
>
<ScrollView
contentContainerClassName="p-4 gap-5"
contentContainerStyle={{ padding: 16, gap: 20 }}
keyboardShouldPersistTaps="handled"
>
{/* Mode toggle */}
@ -195,9 +192,7 @@ export default function NewRoomScreen() {
<Pressable
onPress={handleCreate}
disabled={loading}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator color="#fff" />

View file

@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="flex-1 text-foreground font-semibold text-base">Room Settings</Text>
<Pressable
onPress={handleSave}
disabled={!hasChanges || saving}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator size={14} color="#fff" />
) : (
<Text className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}>
<Text
className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}
>
Save
</Text>
)}
</Pressable>
</View>
<ScrollView contentContainerClassName="px-4 py-6 gap-8">
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 24, gap: 32 }}>
{/* Avatar */}
<View className="items-center gap-3">
<Pressable onPress={handlePickAvatar} disabled={uploadingAvatar}>
@ -117,7 +125,11 @@ export default function RoomSettingsScreen() {
{uploadingAvatar ? (
<ActivityIndicator color="#7c6bff" />
) : avatarUri ? (
<Image source={{ uri: avatarUri }} style={{ width: 96, height: 96 }} contentFit="cover" />
<Image
source={{ uri: avatarUri }}
style={{ width: 96, height: 96 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-3xl font-bold">
{room?.name?.[0]?.toUpperCase() ?? '#'}
@ -165,7 +177,9 @@ export default function RoomSettingsScreen() {
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Room ID</Text>
<View className="bg-surface border border-border rounded-xl px-4 py-3">
<Text className="text-muted-foreground text-sm font-mono" selectable>{id}</Text>
<Text className="text-muted-foreground text-sm font-mono" selectable>
{id}
</Text>
</View>
</View>
</ScrollView>

View file

@ -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 */}
<View className="w-11 h-11 rounded-xl bg-surface border border-border overflow-hidden items-center justify-center shrink-0">
{item.avatar_url ? (
<Image source={{ uri: item.avatar_url }} style={{ width: 44, height: 44 }} contentFit="cover" />
<Image
source={{ uri: item.avatar_url }}
style={{ width: 44, height: 44 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
)}
@ -110,9 +106,7 @@ export default function SearchScreen() {
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
{name}
</Text>
{item.join_rule === 'public' ? null : (
<Lock size={11} color="#6b7280" />
)}
{item.join_rule === 'public' ? null : <Lock size={11} color="#6b7280" />}
</View>
{item.topic && (
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={2}>
@ -129,9 +123,7 @@ export default function SearchScreen() {
<Pressable
onPress={() => 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 ? (
<ActivityIndicator size={14} color="#fff" />
@ -147,10 +139,7 @@ export default function SearchScreen() {
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable
onPress={() => router.back()}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">Explore rooms</Text>

View file

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

View file

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

View file

@ -38,18 +38,14 @@ export default function ImageViewer({ uri, onClose }: Props) {
<View className="absolute top-0 left-0 right-0 z-10 flex-row justify-between p-4 pt-12">
<Pressable
onPress={onClose}
className={({ pressed }) =>
`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"
>
<X size={20} color="#fff" />
</Pressable>
<Pressable
onPress={handleSave}
disabled={saving}
className={({ pressed }) =>
`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"
>
<DownloadSimple size={20} color="#fff" />
</Pressable>

View file

@ -66,7 +66,7 @@ function AvatarCircle({
);
if (!onPress) return inner;
return (
<Pressable onPress={onPress} className={({ pressed }) => `${pressed ? 'opacity-60' : ''}`}>
<Pressable onPress={onPress} className="active:opacity-60">
{inner}
</Pressable>
);
@ -113,17 +113,14 @@ function ReactionDetailsModal({
<View className="flex-1 bg-background">
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Reactions</Text>
<Pressable
onPress={onClose}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<Pressable onPress={onClose} className="p-1 active:opacity-50">
<Text className="text-primary text-base">Done</Text>
</Pressable>
</View>
<ScrollView
horizontal
className="border-b border-border"
contentContainerClassName="px-3 py-2 gap-2"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}
>
{reactions.map((r) => (
<Pressable
@ -144,7 +141,7 @@ function ReactionDetailsModal({
</Pressable>
))}
</ScrollView>
<ScrollView contentContainerClassName="py-2">
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
{selected?.users.map((userId) => (
<View key={userId} className="flex-row items-center gap-3 px-4 py-2.5">
<View className="w-8 h-8 rounded-full bg-surface border border-border items-center justify-center">

View file

@ -76,9 +76,13 @@ export default function MessageInput({
{/* Context banner: Reply or Edit */}
{(replyTo || isEditing) && (
<View className="flex-row items-center gap-2 px-3 pt-2 pb-1">
<View className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`} />
<View
className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`}
/>
<View className="flex-1">
<Text className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}>
<Text
className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}
>
{isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
@ -87,7 +91,7 @@ export default function MessageInput({
</View>
<Pressable
onPress={isEditing ? onCancelEdit : onCancelReply}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
className="p-1 active:opacity-50"
>
<X size={16} color="#6b7280" />
</Pressable>
@ -99,9 +103,7 @@ export default function MessageInput({
{onAttach && !isEditing && (
<Pressable
onPress={onAttach}
className={({ pressed }) =>
`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"
>
<Paperclip size={20} color="#6b7280" />
</Pressable>
@ -126,9 +128,7 @@ export default function MessageInput({
{showMic ? (
<Pressable
onPress={onVoiceRecord}
className={({ pressed }) =>
`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"
>
<Microphone size={20} color="#7c6bff" />
</Pressable>
@ -136,13 +136,13 @@ export default function MessageInput({
<Pressable
onPress={handleSubmit}
disabled={!canSend}
className={({ pressed }) =>
`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 ? (
<PencilSimple size={16} weight="bold" color={canSend ? '#fff' : '#6b7280'} />

View file

@ -38,9 +38,7 @@ export default function RoomListItem({ room, onPress }: Props) {
return (
<Pressable
onPress={onPress}
className={({ pressed }) =>
`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 */}
<View className="relative">
@ -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) {
}`}
>
<Text className="text-white text-xs font-bold leading-none">
{hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount}
{hasHighlight
? room.highlightCount
: room.unreadCount > 99
? '99+'
: room.unreadCount}
</Text>
</View>
)}

View file

@ -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 (
<Modal
visible={!!userId}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Modal visible={!!userId} transparent animationType="fade" onRequestClose={onClose}>
<Pressable className="flex-1 bg-black/60" onPress={onClose}>
<SafeAreaView className="flex-1 justify-end" edges={['bottom']}>
<Pressable onPress={(e) => e.stopPropagation()}>
@ -98,12 +92,20 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Close */}
<View className="absolute top-3 right-4 z-10">
<Pressable onPress={onClose} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Pressable onPress={onClose} className="p-1 active:opacity-50">
<X size={20} color="#6b7280" />
</Pressable>
</View>
<ScrollView contentContainerClassName="px-6 pt-4 pb-8 items-center gap-4">
<ScrollView
contentContainerStyle={{
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 32,
alignItems: 'center',
gap: 16,
}}
>
{loading ? (
<ActivityIndicator color="#7c6bff" className="py-10" />
) : profile ? (
@ -111,7 +113,11 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Avatar */}
<View className="w-24 h-24 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
{profile.avatarUrl ? (
<Image source={{ uri: profile.avatarUrl }} style={{ width: 96, height: 96 }} contentFit="cover" />
<Image
source={{ uri: profile.avatarUrl }}
style={{ width: 96, height: 96 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-4xl font-semibold">{initial}</Text>
)}
@ -119,17 +125,19 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Name */}
<View className="items-center gap-1">
<Text className="text-foreground text-xl font-bold">{profile.displayName}</Text>
<Text className="text-muted-foreground text-sm" selectable>{profile.userId}</Text>
<Text className="text-foreground text-xl font-bold">
{profile.displayName}
</Text>
<Text className="text-muted-foreground text-sm" selectable>
{profile.userId}
</Text>
</View>
{/* Actions */}
{profile.userId !== credentials?.userId && (
<Pressable
onPress={handleStartDM}
className={({ pressed }) =>
`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"
>
<ChatCircle size={18} color="#fff" weight="fill" />
<Text className="text-white font-semibold">

View file

@ -47,9 +47,7 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) {
<View className="flex-row items-center gap-3 px-3 py-2.5 min-w-[160px]">
<Pressable
onPress={handleToggle}
className={({ pressed }) =>
`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 ? (
<ActivityIndicator size={14} color={iconColor} />

View file

@ -94,9 +94,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
{/* Discard */}
<Pressable
onPress={handleDiscard}
className={({ pressed }) =>
`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"
>
<Trash size={18} color="#ef4444" />
</Pressable>
@ -115,9 +113,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
<Pressable
onPress={handleSend}
disabled={sending || duration < 1}
className={({ pressed }) =>
`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`}
>
<PaperPlaneRight size={18} color={duration >= 1 ? '#fff' : '#6b7280'} weight="fill" />
</Pressable>

60
apps/mukke/CLAUDE.md Normal file
View file

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

6
apps/mukke/apps/mobile/.gitignore vendored Normal file
View file

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

View file

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

View file

@ -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 (
<View style={{ flex: 1 }}>
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf="music.note.list" />
<Label>Bibliothek</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="playlists">
<Icon sf="list.bullet" />
<Label>Playlists</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="search">
<Icon sf="magnifyingglass" />
<Label>Suche</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf="gearshape.fill" />
<Label>Einstellungen</Label>
</NativeTabs.Trigger>
</NativeTabs>
<MiniPlayer />
</View>
);
}

View file

@ -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 (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
title: 'Bibliothek',
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{activeTab === 'songs' && (
<SortMenu
currentField={sortField}
currentDirection={sortDirection}
onSort={(field, dir) => {
setSortField(field);
setSortDirection(dir);
}}
/>
)}
<ImportButton />
</View>
),
}}
/>
<SegmentedControl segments={SEGMENTS} selected={activeTab} onSelect={setActiveTab} />
{activeTab === 'songs' && <SongList songs={songs} />}
{activeTab === 'albums' && <AlbumGrid albums={albums} />}
{activeTab === 'artists' && <ArtistList artists={artists} />}
{activeTab === 'genres' && <GenreList genres={genres} />}
</View>
);
}

View file

@ -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 (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
title: 'Playlists',
headerRight: () => (
<Pressable onPress={() => router.push('/playlist/new')} style={{ padding: 8 }}>
<Ionicons name="add" size={28} color={colors.primary} />
</Pressable>
),
}}
/>
{playlists.length === 0 ? (
<EmptyState
icon="list-outline"
title="Keine Playlists"
message="Erstelle eine Playlist über den + Button."
/>
) : (
<FlatList
data={playlists}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<ListItem
title={item.name}
subtitle={item.description || undefined}
left={
<View
style={{
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: colors.primary + '20',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="musical-notes" size={24} color={colors.primary} />
</View>
}
onPress={() => router.push(`/playlist/${item.id}`)}
showChevron
/>
)}
/>
)}
</View>
);
}

View file

@ -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<Song[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const handleSearch = useCallback(async (text: string) => {
setQuery(text);
if (text.trim().length < 2) {
setResults([]);
setHasSearched(false);
return;
}
setHasSearched(true);
const songs = await searchSongs(text.trim());
setResults(songs);
}, []);
return (
<View style={{ flex: 1 }}>
<Stack.Screen options={{ title: 'Suche' }} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundTertiary,
borderRadius: 10,
margin: 16,
paddingHorizontal: 12,
}}
>
<Ionicons name="search" size={18} color={colors.textTertiary} />
<TextInput
value={query}
onChangeText={handleSearch}
placeholder="Songs, Künstler, Alben..."
placeholderTextColor={colors.textTertiary}
style={{
flex: 1,
paddingVertical: 10,
paddingHorizontal: 8,
fontSize: 16,
color: colors.text,
}}
autoCorrect={false}
clearButtonMode="while-editing"
/>
</View>
{!hasSearched ? (
<EmptyState
icon="search-outline"
title="Suche"
message="Suche nach Songs, Künstlern oder Alben."
/>
) : results.length === 0 ? (
<EmptyState
icon="search-outline"
title="Keine Ergebnisse"
message={`Keine Treffer für "${query}".`}
/>
) : (
<SongList songs={results} emptyTitle="Keine Ergebnisse" />
)}
</View>
);
}

View file

@ -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 (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 120 }}>
<Stack.Screen options={{ title: 'Einstellungen' }} />
{/* Appearance */}
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
marginTop: 24,
marginHorizontal: 16,
marginBottom: 8,
textTransform: 'uppercase',
}}
>
Darstellung
</Text>
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
}}
>
<Text style={{ fontSize: 16, color: colors.text }}>Dark Mode</Text>
<Switch value={isDarkMode} onValueChange={toggleTheme} />
</View>
<View style={{ height: 0.5, backgroundColor: colors.border, marginLeft: 16 }} />
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 16, color: colors.text, marginBottom: 12 }}>Akzentfarbe</Text>
<View style={{ flexDirection: 'row', gap: 12 }}>
{variants.map((v) => (
<Pressable
key={v.key}
onPress={() => setThemeVariant(v.key)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: v.color,
borderWidth: themeVariant === v.key ? 3 : 0,
borderColor: colors.text,
}}
/>
))}
</View>
</View>
</View>
{/* Import */}
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
marginTop: 24,
marginHorizontal: 16,
marginBottom: 8,
textTransform: 'uppercase',
}}
>
Musik
</Text>
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
<Pressable
onPress={handleImport}
style={{ flexDirection: 'row', alignItems: 'center', padding: 16 }}
>
<Ionicons
name="add-circle-outline"
size={22}
color={colors.primary}
style={{ marginRight: 12 }}
/>
<Text style={{ fontSize: 16, color: colors.primary }}>Songs importieren</Text>
</Pressable>
</View>
{/* Storage */}
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
marginTop: 24,
marginHorizontal: 16,
marginBottom: 8,
textTransform: 'uppercase',
}}
>
Speicher
</Text>
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
<View style={{ padding: 16 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 15, color: colors.text }}>Songs</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary }}>{songCount}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 15, color: colors.text }}>Musik</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
{formatFileSize(storageInfo.musicSize)}
</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 15, color: colors.text }}>Cover Art</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
{formatFileSize(storageInfo.artworkSize)}
</Text>
</View>
</View>
</View>
{/* About */}
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
marginTop: 24,
marginHorizontal: 16,
marginBottom: 8,
textTransform: 'uppercase',
}}
>
Info
</Text>
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
<View style={{ padding: 16 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 15, color: colors.text }}>Version</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary }}>1.0.0</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 15, color: colors.text }}>Mukke</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary }}>Offline Music Player</Text>
</View>
</View>
</View>
</ScrollView>
);
}

View file

@ -0,0 +1,16 @@
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className="items-center flex-1 justify-center p-5">
<Text className="text-xl font-bold">Diese Seite existiert nicht.</Text>
<Link href="/" className="mt-4 pt-4">
<Text className="text-base text-[#2e78b7]">Zur Bibliothek</Text>
</Link>
</View>
</>
);
}

View file

@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ThemeProvider>
{({ isDarkMode }) => (
<ThemeWrapper>
<AudioProvider>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: isDarkMode ? '#1E1E1E' : 'transparent',
},
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
headerTitleStyle: {
color: isDarkMode ? '#FFFFFF' : '#000000',
},
contentStyle: {
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
},
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="player"
options={{
presentation: 'modal',
headerShown: false,
}}
/>
<Stack.Screen
name="queue"
options={{
presentation: 'modal',
title: 'Warteschlange',
}}
/>
<Stack.Screen
name="album/[id]"
options={{ title: 'Album', headerBackTitle: 'Zurück' }}
/>
<Stack.Screen
name="artist/[id]"
options={{ title: 'Künstler', headerBackTitle: 'Zurück' }}
/>
<Stack.Screen
name="playlist/[id]"
options={{ title: 'Playlist', headerBackTitle: 'Zurück' }}
/>
<Stack.Screen
name="playlist/new"
options={{
presentation: 'modal',
title: 'Neue Playlist',
}}
/>
</Stack>
</AudioProvider>
</ThemeWrapper>
)}
</ThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

View file

@ -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<Song[]>([]);
useEffect(() => {
if (albumName) {
getSongsByAlbum(albumName).then(setSongs);
}
}, [albumName]);
const coverArt = songs.find((s) => s.coverArtPath)?.coverArtPath || null;
const artist = songs[0]?.albumArtist || songs[0]?.artist || 'Unbekannt';
const year = songs[0]?.year;
return (
<View style={{ flex: 1 }}>
{/* Album Header */}
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
<Artwork uri={coverArt} size={180} />
<Text
style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}
numberOfLines={2}
>
{albumName}
</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
{artist}
{year ? ` · ${year}` : ''} · {songs.length} Songs
</Text>
</View>
<SongList
songs={songs}
onSongPress={(song, index) => playSong(song, songs, index)}
emptyTitle="Keine Songs"
/>
</View>
);
}

View file

@ -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<Song[]>([]);
useEffect(() => {
if (artistName) {
getSongsByArtist(artistName).then(setSongs);
}
}, [artistName]);
const albumCount = new Set(songs.map((s) => s.album).filter(Boolean)).size;
return (
<View style={{ flex: 1 }}>
{/* Artist Header */}
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
<Artwork uri={null} size={120} rounded />
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text, marginTop: 12 }}>
{artistName}
</Text>
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
{songs.length} Songs · {albumCount} Alben
</Text>
</View>
<SongList
songs={songs}
onSongPress={(song, index) => playSong(song, songs, index)}
emptyTitle="Keine Songs"
/>
</View>
);
}

View file

@ -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 (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.textSecondary }}>Kein Song wird abgespielt</Text>
</View>
);
}
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
paddingTop: insets.top + 8,
paddingBottom: insets.bottom + 16,
}}
>
{/* Header */}
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
marginBottom: 24,
}}
>
<Pressable onPress={() => router.back()} style={{ padding: 4 }}>
<Ionicons name="chevron-down" size={28} color={colors.text} />
</Pressable>
<Text style={{ fontSize: 13, color: colors.textSecondary, fontWeight: '600' }}>
WIRD ABGESPIELT
</Text>
<Pressable onPress={() => router.push('/queue')} style={{ padding: 4 }}>
<Ionicons name="list" size={24} color={colors.text} />
</Pressable>
</View>
{/* Artwork */}
<View
style={{ alignItems: 'center', paddingHorizontal: 40, flex: 1, justifyContent: 'center' }}
>
<Artwork uri={currentSong.coverArtPath} size={300} />
</View>
{/* Song Info */}
<View style={{ paddingHorizontal: 24, marginBottom: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text }} numberOfLines={1}>
{currentSong.title}
</Text>
<Text
style={{ fontSize: 16, color: colors.textSecondary, marginTop: 4 }}
numberOfLines={1}
>
{currentSong.artist || 'Unbekannt'}
</Text>
</View>
<Pressable onPress={() => toggleFavorite(currentSong.id)} style={{ padding: 8 }}>
<Ionicons
name={currentSong.favorite ? 'heart' : 'heart-outline'}
size={24}
color={currentSong.favorite ? colors.primary : colors.textSecondary}
/>
</Pressable>
</View>
</View>
{/* Progress */}
<ProgressBar position={position} duration={duration} onSeek={seekTo} />
{/* Transport */}
<View style={{ paddingVertical: 24 }}>
<TransportControls size="large" />
</View>
</View>
);
}

View file

@ -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<Playlist | null>(null);
const [songs, setSongs] = useState<Song[]>([]);
const [showPicker, setShowPicker] = useState(false);
const loadData = async () => {
if (!id) return;
const [p, s] = await Promise.all([getPlaylistById(id), getPlaylistSongs(id)]);
setPlaylist(p);
setSongs(s);
};
useEffect(() => {
loadData();
}, [id]);
const handleAddSongs = async (selected: Song[]) => {
if (!id) return;
for (const song of selected) {
await addSongToPlaylist(id, song.id);
}
await loadData();
};
const handleLongPress = (song: Song) => {
Alert.alert('Song entfernen', `"${song.title}" aus der Playlist entfernen?`, [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Entfernen',
style: 'destructive',
onPress: async () => {
if (id) {
await removeSongFromPlaylist(id, song.id);
await loadData();
}
},
},
]);
};
return (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
title: playlist?.name || 'Playlist',
headerRight: () => (
<Pressable onPress={() => setShowPicker(true)} style={{ padding: 8 }}>
<Ionicons name="add" size={28} color={colors.primary} />
</Pressable>
),
}}
/>
{playlist && (
<View style={{ padding: 16, alignItems: 'center' }}>
<View
style={{
width: 120,
height: 120,
borderRadius: 12,
backgroundColor: colors.primary + '20',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="musical-notes" size={48} color={colors.primary} />
</View>
<Text style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}>
{playlist.name}
</Text>
{playlist.description && (
<Text style={{ fontSize: 14, color: colors.textSecondary, marginTop: 4 }}>
{playlist.description}
</Text>
)}
<Text style={{ fontSize: 13, color: colors.textTertiary, marginTop: 4 }}>
{songs.length} Songs
</Text>
</View>
)}
<SongList
songs={songs}
onSongPress={(song, index) => playSong(song, songs, index)}
emptyTitle="Playlist ist leer"
emptyMessage="Füge Songs über den + Button hinzu."
/>
<SongPicker
visible={showPicker}
onClose={() => setShowPicker(false)}
onSelect={handleAddSongs}
excludeIds={songs.map((s) => s.id)}
/>
</View>
);
}

View file

@ -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 (
<View style={{ flex: 1, backgroundColor: colors.background, padding: 20 }}>
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>Name</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Playlist Name"
placeholderTextColor={colors.textTertiary}
style={{
backgroundColor: colors.card,
borderRadius: 10,
padding: 14,
fontSize: 16,
color: colors.text,
marginBottom: 20,
}}
autoFocus
/>
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>
Beschreibung (optional)
</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Beschreibung..."
placeholderTextColor={colors.textTertiary}
style={{
backgroundColor: colors.card,
borderRadius: 10,
padding: 14,
fontSize: 16,
color: colors.text,
marginBottom: 32,
minHeight: 80,
}}
multiline
textAlignVertical="top"
/>
<Pressable
onPress={handleCreate}
disabled={!name.trim()}
style={{
backgroundColor: name.trim() ? colors.primary : colors.backgroundTertiary,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 17,
fontWeight: '600',
color: name.trim() ? '#FFFFFF' : colors.textTertiary,
}}
>
Erstellen
</Text>
</Pressable>
</View>
);
}

View file

@ -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 (
<View style={{ flex: 1, backgroundColor: colors.background }}>
{currentSong && (
<View style={{ padding: 16, borderBottomWidth: 0.5, borderBottomColor: colors.border }}>
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
fontWeight: '600',
marginBottom: 8,
}}
>
AKTUELLER SONG
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Artwork uri={currentSong.coverArtPath} size={48} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text
style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}
numberOfLines={1}
>
{currentSong.title}
</Text>
<Text style={{ fontSize: 14, color: colors.textSecondary }} numberOfLines={1}>
{currentSong.artist || 'Unbekannt'}
</Text>
</View>
</View>
</View>
)}
<Text
style={{
fontSize: 13,
color: colors.textSecondary,
fontWeight: '600',
padding: 16,
paddingBottom: 8,
}}
>
ALS NÄCHSTES
</Text>
<FlatList
data={queue.slice(currentIndex + 1)}
keyExtractor={(item, index) => `${item.id}-${index}`}
contentContainerStyle={{ paddingBottom: 40 }}
renderItem={({ item, index }) => (
<ListItem
title={item.title}
subtitle={item.artist || 'Unbekannt'}
trailing={formatDuration(item.duration)}
left={<Artwork uri={item.coverArtPath} size={40} />}
onPress={() => playSong(item, queue, currentIndex + 1 + index)}
/>
)}
/>
</View>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

@ -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 (
<EmptyState
icon="disc-outline"
title="Keine Alben"
message="Importierte Songs werden nach Alben gruppiert."
/>
);
}
return (
<FlatList
data={albums}
keyExtractor={(item) => item.name}
numColumns={2}
contentContainerStyle={{ padding: 12, paddingBottom: 100 }}
columnWrapperStyle={{ gap: 12 }}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/album/${encodeURIComponent(item.name)}`)}
style={{ width: itemSize }}
>
{item.coverArtPath ? (
<Image
source={{ uri: item.coverArtPath }}
style={{ width: itemSize, height: itemSize, borderRadius: 8 }}
/>
) : (
<View
style={{
width: itemSize,
height: itemSize,
borderRadius: 8,
backgroundColor: colors.backgroundTertiary,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="disc-outline" size={48} color={colors.textTertiary} />
</View>
)}
<Text
style={{ fontSize: 14, fontWeight: '600', color: colors.text, marginTop: 6 }}
numberOfLines={1}
>
{item.name}
</Text>
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
{item.artist || 'Unbekannt'} · {item.songCount} Songs
</Text>
</Pressable>
)}
/>
);
}

View file

@ -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 (
<EmptyState
icon="person-outline"
title="Keine Künstler"
message="Importierte Songs werden nach Künstlern gruppiert."
/>
);
}
return (
<FlatList
data={artists}
keyExtractor={(item) => item.name}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<ListItem
title={item.name}
subtitle={`${item.songCount} Songs · ${item.albumCount} Alben`}
left={<Artwork uri={null} size={44} rounded />}
onPress={() => router.push(`/artist/${encodeURIComponent(item.name)}`)}
showChevron
/>
)}
/>
);
}

View file

@ -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 (
<Image
source={{ uri }}
style={{
width: size,
height: size,
borderRadius: rounded ? size / 2 : 8,
}}
/>
);
}
return (
<View
style={{
width: size,
height: size,
borderRadius: rounded ? size / 2 : 8,
backgroundColor: colors.backgroundTertiary,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="musical-note" size={size * 0.4} color={colors.textTertiary} />
</View>
);
}

View file

@ -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 (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={{
backgroundColor: bgColor,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
opacity: disabled ? 0.5 : 1,
alignItems: 'center',
}}
>
{loading ? (
<ActivityIndicator color={textColor} size="small" />
) : (
<Text style={{ color: textColor, fontWeight: '600', fontSize: 16 }}>{title}</Text>
)}
</Pressable>
);
}

View file

@ -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 (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<Ionicons name={icon} size={64} color={colors.textTertiary} />
<Text
style={{
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginTop: 16,
textAlign: 'center',
}}
>
{title}
</Text>
{message && (
<Text
style={{
fontSize: 15,
color: colors.textSecondary,
marginTop: 8,
textAlign: 'center',
}}
>
{message}
</Text>
)}
</View>
);
}

View file

@ -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 (
<EmptyState
icon="albums-outline"
title="Keine Genres"
message="Genre-Tags werden beim Import aus den Metadaten gelesen."
/>
);
}
const handlePress = async (genre: Genre) => {
const songs = await getSongsByGenre(genre.name);
if (songs.length > 0) {
playSong(songs[0], songs, 0);
}
};
return (
<FlatList
data={genres}
keyExtractor={(item) => item.name}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<ListItem
title={item.name}
subtitle={`${item.songCount} Songs`}
left={
<View
style={{
width: 44,
height: 44,
borderRadius: 8,
backgroundColor: colors.primary + '20',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="musical-notes" size={22} color={colors.primary} />
</View>
}
onPress={() => handlePress(item)}
/>
)}
/>
);
}

View file

@ -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 (
<Pressable onPress={handleImport} disabled={importing} style={{ padding: 8 }}>
<Ionicons name={importing ? 'hourglass' : 'add'} size={28} color={colors.primary} />
</Pressable>
);
}

View file

@ -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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: pressed ? colors.backgroundTertiary : 'transparent',
})}
>
{left && <View style={{ marginRight: 12 }}>{left}</View>}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 16, color: colors.text }} numberOfLines={1}>
{title}
</Text>
{subtitle && (
<Text
style={{ fontSize: 13, color: colors.textSecondary, marginTop: 2 }}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</View>
{trailing && (
<Text style={{ fontSize: 13, color: colors.textTertiary, marginLeft: 8 }}>{trailing}</Text>
)}
{showChevron && (
<Ionicons
name="chevron-forward"
size={18}
color={colors.textTertiary}
style={{ marginLeft: 4 }}
/>
)}
</Pressable>
);
}

View file

@ -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 (
<Pressable
onPress={() => router.push('/player')}
style={{
position: 'absolute',
bottom: 49,
left: 0,
right: 0,
backgroundColor: colors.card,
borderTopWidth: 0.5,
borderTopColor: colors.border,
}}
>
{/* Progress indicator */}
<View style={{ height: 2, backgroundColor: colors.backgroundTertiary }}>
<View
style={{
height: 2,
backgroundColor: colors.primary,
width: `${progress * 100}%`,
}}
/>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
}}
>
<Artwork uri={currentSong.coverArtPath} size={40} />
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
<Text style={{ fontSize: 14, fontWeight: '600', color: colors.text }} numberOfLines={1}>
{currentSong.title}
</Text>
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
{currentSong.artist || 'Unbekannt'}
</Text>
</View>
<Pressable onPress={isPlaying ? pause : play} style={{ padding: 8 }}>
<Ionicons name={isPlaying ? 'pause' : 'play'} size={24} color={colors.text} />
</Pressable>
<Pressable onPress={playNext} style={{ padding: 8 }}>
<Ionicons name="play-skip-forward" size={20} color={colors.text} />
</Pressable>
</View>
</Pressable>
);
}

View file

@ -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 (
<View style={{ width: '100%', paddingHorizontal: 20 }}>
<Slider
value={duration > 0 ? position / duration : 0}
onSlidingComplete={(value) => onSeek(value * duration)}
minimumValue={0}
maximumValue={1}
minimumTrackTintColor={colors.primary}
maximumTrackTintColor={colors.backgroundTertiary}
thumbTintColor={colors.primary}
/>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }}>
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
{formatDuration(position)}
</Text>
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
-{formatDuration(Math.max(0, duration - position))}
</Text>
</View>
</View>
);
}

View file

@ -0,0 +1,57 @@
import { Pressable, View, Text } from 'react-native';
import { useTheme } from '~/utils/themeContext';
interface SegmentedControlProps<T extends string> {
segments: { key: T; label: string }[];
selected: T;
onSelect: (key: T) => void;
}
export function SegmentedControl<T extends string>({
segments,
selected,
onSelect,
}: SegmentedControlProps<T>) {
const { colors } = useTheme();
return (
<View
style={{
flexDirection: 'row',
backgroundColor: colors.backgroundTertiary,
borderRadius: 8,
padding: 2,
marginHorizontal: 16,
marginVertical: 8,
}}
>
{segments.map((seg) => {
const isActive = seg.key === selected;
return (
<Pressable
key={seg.key}
onPress={() => onSelect(seg.key)}
style={{
flex: 1,
paddingVertical: 8,
borderRadius: 6,
backgroundColor: isActive ? colors.card : 'transparent',
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 13,
fontWeight: isActive ? '600' : '400',
color: isActive ? colors.text : colors.textSecondary,
}}
>
{seg.label}
</Text>
</Pressable>
);
})}
</View>
);
}

View file

@ -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 <EmptyState title={emptyTitle} message={emptyMessage} />;
}
return (
<FlatList
data={songs}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<ListItem
title={item.title}
subtitle={[item.artist, item.album].filter(Boolean).join(' · ')}
trailing={formatDuration(item.duration)}
left={<Artwork uri={item.coverArtPath} size={44} />}
onPress={() => handlePress(item, index)}
/>
)}
contentContainerStyle={{ paddingBottom: 100 }}
/>
);
}

View file

@ -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<Song[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
if (visible) {
getAllSongs().then((all) => {
setSongs(all.filter((s) => !excludeIds.includes(s.id)));
});
setSelected(new Set());
}
}, [visible]);
const toggleSelection = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleDone = () => {
const selectedSongs = songs.filter((s) => selected.has(s.id));
onSelect(selectedSongs);
onClose();
};
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 0.5,
borderBottomColor: colors.border,
}}
>
<Pressable onPress={onClose}>
<Text style={{ fontSize: 16, color: colors.primary }}>Abbrechen</Text>
</Pressable>
<Text style={{ fontSize: 17, fontWeight: '600', color: colors.text }}>
Songs auswählen
</Text>
<Pressable onPress={handleDone}>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}>
Fertig ({selected.size})
</Text>
</Pressable>
</View>
<FlatList
data={songs}
keyExtractor={(item) => item.id}
renderItem={({ item }) => {
const isSelected = selected.has(item.id);
return (
<Pressable
onPress={() => toggleSelection(item.id)}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
}}
>
<Ionicons
name={isSelected ? 'checkmark-circle' : 'ellipse-outline'}
size={24}
color={isSelected ? colors.primary : colors.textTertiary}
style={{ marginRight: 12 }}
/>
<Artwork uri={item.coverArtPath} size={40} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={{ fontSize: 15, color: colors.text }} numberOfLines={1}>
{item.title}
</Text>
<Text style={{ fontSize: 13, color: colors.textSecondary }} numberOfLines={1}>
{item.artist || 'Unbekannt'}
</Text>
</View>
</Pressable>
);
}}
/>
</View>
</Modal>
);
}

View file

@ -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 (
<>
<Pressable onPress={() => setVisible(true)} style={{ padding: 8 }}>
<Ionicons name="swap-vertical" size={22} color={colors.primary} />
</Pressable>
<Modal visible={visible} transparent animationType="fade">
<Pressable
onPress={() => setVisible(false)}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }}
>
<View
style={{
backgroundColor: colors.card,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 20,
paddingBottom: 40,
}}
>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text, marginBottom: 16 }}>
Sortieren
</Text>
{SORT_OPTIONS.map((opt) => {
const isActive = opt.field === currentField;
return (
<Pressable
key={opt.field}
onPress={() => {
if (isActive) {
onSort(opt.field, currentDirection === 'asc' ? 'desc' : 'asc');
} else {
onSort(opt.field, 'asc');
}
setVisible(false);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 0.5,
borderBottomColor: colors.border,
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
color: isActive ? colors.primary : colors.text,
fontWeight: isActive ? '600' : '400',
}}
>
{opt.label}
</Text>
{isActive && (
<Ionicons
name={currentDirection === 'asc' ? 'arrow-up' : 'arrow-down'}
size={18}
color={colors.primary}
/>
)}
</Pressable>
);
})}
</View>
</Pressable>
</Modal>
</>
);
}

View file

@ -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<ThemeWrapperProps> = ({ children, className = '' }) => {
const { isDarkMode } = useTheme();
return (
<View
className={`${isDarkMode ? 'dark bg-background-dark' : 'bg-background-light'} flex-1 ${className}`}
>
{children}
</View>
);
};

View file

@ -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 (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: size === 'large' ? 32 : 20,
}}
>
{size === 'large' && (
<Pressable onPress={toggleShuffle}>
<Ionicons
name="shuffle"
size={24}
color={shuffleMode === 'on' ? colors.primary : colors.textSecondary}
/>
</Pressable>
)}
<Pressable onPress={playPrevious}>
<Ionicons name="play-skip-back" size={iconSize} color={colors.text} />
</Pressable>
<Pressable
onPress={isPlaying ? pause : play}
style={{
width: playSize,
height: playSize,
borderRadius: playSize / 2,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons
name={isPlaying ? 'pause' : 'play'}
size={playSize * 0.5}
color="#FFFFFF"
style={{ marginLeft: isPlaying ? 0 : 2 }}
/>
</Pressable>
<Pressable onPress={playNext}>
<Ionicons name="play-skip-forward" size={iconSize} color={colors.text} />
</Pressable>
{size === 'large' && (
<Pressable onPress={toggleRepeat}>
<Ionicons
name={getRepeatIcon(repeatMode)}
size={24}
color={repeatMode !== 'off' ? colors.primary : colors.textSecondary}
/>
{repeatMode === 'one' && (
<View
style={{
position: 'absolute',
top: -4,
right: -6,
backgroundColor: colors.primary,
borderRadius: 6,
width: 12,
height: 12,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="remove" size={8} color="#FFFFFF" />
</View>
)}
</Pressable>
)}
</View>
);
}

View file

@ -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<AudioContextType>({
play: () => {},
pause: () => {},
seekTo: () => {},
playNext: () => {},
playPrevious: () => {},
});
export const useAudio = () => useContext(AudioCtx);
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const player = useAudioPlayer(null);
const status = useAudioPlayerStatus(player);
const { currentSong, isPlaying, setPlaying, setPosition, setDuration, nextSong, previousSong } =
usePlayerStore();
const hasCountedPlay = useRef(false);
const lastSongId = useRef<string | null>(null);
// Configure audio mode for background playback
useEffect(() => {
setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
});
}, []);
// Load song when currentSong changes
useEffect(() => {
if (!currentSong) return;
if (lastSongId.current === currentSong.id) return;
lastSongId.current = currentSong.id;
hasCountedPlay.current = false;
player.replace({ uri: currentSong.filePath });
// Set lock screen metadata
player.setActiveForLockScreen(true, {
title: currentSong.title,
artist: currentSong.artist || undefined,
albumTitle: currentSong.album || undefined,
artworkSource: currentSong.coverArtPath ? { uri: currentSong.coverArtPath } : undefined,
});
player.play();
}, [currentSong?.id]);
// Sync play/pause state
useEffect(() => {
if (!currentSong) return;
if (isPlaying && !status.playing) {
player.play();
} else if (!isPlaying && status.playing) {
player.pause();
}
}, [isPlaying]);
// Update position and duration from player status
useEffect(() => {
if (status.currentTime !== undefined) {
setPosition(status.currentTime);
}
if (status.duration && status.duration > 0) {
setDuration(status.duration);
// Save duration to DB if not yet stored
if (currentSong && !currentSong.duration) {
updateSongDuration(currentSong.id, status.duration);
}
}
}, [status.currentTime, status.duration]);
// Count play after 10 seconds
useEffect(() => {
if (currentSong && status.currentTime > 10 && !hasCountedPlay.current) {
hasCountedPlay.current = true;
updatePlayStats(currentSong.id);
}
}, [status.currentTime]);
// Auto-advance when track ends
useEffect(() => {
if (status.didJustFinish) {
const next = nextSong();
if (!next) {
setPlaying(false);
}
}
}, [status.didJustFinish]);
const play = useCallback(() => {
player.play();
setPlaying(true);
}, [player]);
const pause = useCallback(() => {
player.pause();
setPlaying(false);
}, [player]);
const seekTo = useCallback(
(position: number) => {
player.seekTo(position);
setPosition(position);
},
[player]
);
const playNext = useCallback(() => {
const song = nextSong();
if (!song) setPlaying(false);
}, []);
const playPrevious = useCallback(() => {
const song = previousSong();
if (song && song.id === currentSong?.id) {
// Restart current song
player.seekTo(0);
setPosition(0);
}
}, [currentSong?.id, player]);
return (
<AudioCtx.Provider value={{ play, pause, seekTo, playNext, playPrevious }}>
{children}
</AudioCtx.Provider>
);
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View file

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

View file

@ -0,0 +1,17 @@
import { setAudioModeAsync } from 'expo-audio';
export { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
export async function configureAudioMode(): Promise<void> {
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
});
}
export function formatDuration(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,67 @@
import * as SQLite from 'expo-sqlite';
let db: SQLite.SQLiteDatabase | null = null;
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
if (db) return db;
db = await SQLite.openDatabaseAsync('mukke.db');
await initializeDatabase(db);
return db;
}
async function initializeDatabase(database: SQLite.SQLiteDatabase): Promise<void> {
await database.execAsync(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT,
album TEXT,
albumArtist TEXT,
genre TEXT,
trackNumber INTEGER,
discNumber INTEGER,
year INTEGER,
duration REAL,
filePath TEXT NOT NULL,
fileSize INTEGER,
coverArtPath TEXT,
addedAt TEXT NOT NULL,
lastPlayedAt TEXT,
playCount INTEGER DEFAULT 0,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
coverArtPath TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_songs (
id TEXT PRIMARY KEY,
playlistId TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
songId TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
sortOrder INTEGER NOT NULL,
addedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album);
CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
CREATE INDEX IF NOT EXISTS idx_songs_favorite ON songs(favorite);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlistId);
`);
}
export async function closeDatabase(): Promise<void> {
if (db) {
await db.closeAsync();
db = null;
}
}

View file

@ -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<void> {
const musicInfo = await FileSystem.getInfoAsync(MUSIC_DIR);
if (!musicInfo.exists) {
await FileSystem.makeDirectoryAsync(MUSIC_DIR, { intermediates: true });
}
const artworkInfo = await FileSystem.getInfoAsync(ARTWORK_DIR);
if (!artworkInfo.exists) {
await FileSystem.makeDirectoryAsync(ARTWORK_DIR, { intermediates: true });
}
}
export function getFileExtension(uri: string): string {
const parts = uri.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'mp3';
}
export async function copyToMusicDirectory(
sourceUri: string
): Promise<{ path: string; id: string }> {
await ensureDirectories();
const id = uuidv4();
const ext = getFileExtension(sourceUri);
const destPath = `${MUSIC_DIR}${id}.${ext}`;
await FileSystem.copyAsync({ from: sourceUri, to: destPath });
return { path: destPath, id };
}
export async function saveArtwork(data: Uint8Array, songId: string): Promise<string> {
await ensureDirectories();
const artworkPath = `${ARTWORK_DIR}${songId}.jpg`;
const base64 = uint8ArrayToBase64(data);
await FileSystem.writeAsStringAsync(artworkPath, base64, {
encoding: FileSystem.EncodingType.Base64,
});
return artworkPath;
}
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export async function deleteFile(path: string): Promise<void> {
const info = await FileSystem.getInfoAsync(path);
if (info.exists) {
await FileSystem.deleteAsync(path);
}
}
export async function getStorageInfo(): Promise<{
musicSize: number;
artworkSize: number;
totalFiles: number;
}> {
let musicSize = 0;
let artworkSize = 0;
let totalFiles = 0;
try {
const musicFiles = await FileSystem.readDirectoryAsync(MUSIC_DIR);
for (const file of musicFiles) {
const info = await FileSystem.getInfoAsync(`${MUSIC_DIR}${file}`);
if (info.exists && !info.isDirectory && 'size' in info) {
musicSize += info.size ?? 0;
totalFiles++;
}
}
} catch {
// Directory might not exist yet
}
try {
const artworkFiles = await FileSystem.readDirectoryAsync(ARTWORK_DIR);
for (const file of artworkFiles) {
const info = await FileSystem.getInfoAsync(`${ARTWORK_DIR}${file}`);
if (info.exists && !info.isDirectory && 'size' in info) {
artworkSize += info.size ?? 0;
}
}
} catch {
// Directory might not exist yet
}
return { musicSize, artworkSize, totalFiles };
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

View file

@ -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<Song[]> {
const result = await DocumentPicker.getDocumentAsync({
type: SUPPORTED_TYPES,
multiple: true,
copyToCacheDirectory: true,
});
if (result.canceled || !result.assets?.length) {
return [];
}
const importedSongs: Song[] = [];
for (const asset of result.assets) {
try {
const song = await importSingleFile(asset);
if (song) {
importedSongs.push(song);
}
} catch (error) {
console.warn(`Failed to import ${asset.name}:`, error);
}
}
return importedSongs;
}
async function importSingleFile(asset: DocumentPicker.DocumentPickerAsset): Promise<Song | null> {
// Copy to permanent storage
const { path: filePath, id } = await copyToMusicDirectory(asset.uri);
// Get file size
const fileInfo = await FileSystem.getInfoAsync(filePath);
const fileSize = fileInfo.exists && 'size' in fileInfo ? (fileInfo.size ?? null) : null;
// Extract metadata
let metadata: Awaited<ReturnType<typeof getAudioMetadata>> | null = null;
try {
metadata = await getAudioMetadata(filePath, [
'title',
'artist',
'album',
'albumArtist',
'genre',
'trackNumber',
'year',
'picture',
]);
} catch (error) {
console.warn('Failed to read metadata:', error);
}
// Save cover art if available
let coverArtPath: string | null = null;
if (metadata?.metadata?.picture) {
try {
const pictureData = metadata.metadata.picture;
if (pictureData && typeof pictureData === 'object' && 'data' in pictureData) {
coverArtPath = await saveArtwork(pictureData.data as Uint8Array, id);
}
} catch (error) {
console.warn('Failed to save cover art:', error);
}
}
// Build title from metadata or filename
const title = (metadata?.metadata?.title as string) || asset.name.replace(/\.[^.]+$/, '');
const song: Song = {
id,
title,
artist: (metadata?.metadata?.artist as string) || null,
album: (metadata?.metadata?.album as string) || null,
albumArtist: (metadata?.metadata?.albumArtist as string) || null,
genre: (metadata?.metadata?.genre as string) || null,
trackNumber: metadata?.metadata?.trackNumber
? parseInt(String(metadata.metadata.trackNumber), 10) || null
: null,
discNumber: null,
year: metadata?.metadata?.year ? parseInt(String(metadata.metadata.year), 10) || null : null,
duration: null,
filePath,
fileSize,
coverArtPath,
addedAt: new Date().toISOString(),
lastPlayedAt: null,
playCount: 0,
favorite: false,
};
await insertSong(song);
return song;
}

View file

@ -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<void> {
const db = await getDatabase();
await db.runAsync(
`INSERT INTO songs (id, title, artist, album, albumArtist, genre, trackNumber, discNumber, year, duration, filePath, fileSize, coverArtPath, addedAt, lastPlayedAt, playCount, favorite)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
song.id,
song.title,
song.artist,
song.album,
song.albumArtist,
song.genre,
song.trackNumber,
song.discNumber,
song.year,
song.duration,
song.filePath,
song.fileSize,
song.coverArtPath,
song.addedAt,
song.lastPlayedAt,
song.playCount,
song.favorite ? 1 : 0
);
}
export async function getAllSongs(
orderBy: string = 'title',
direction: 'ASC' | 'DESC' = 'ASC'
): Promise<Song[]> {
const db = await getDatabase();
const validColumns = ['title', 'artist', 'album', 'addedAt', 'playCount'];
const col = validColumns.includes(orderBy) ? orderBy : 'title';
const dir = direction === 'DESC' ? 'DESC' : 'ASC';
const rows = await db.getAllAsync<Song & { favorite: number }>(
`SELECT * FROM songs ORDER BY ${col} ${dir}`
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function getSongById(id: string): Promise<Song | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<Song & { favorite: number }>(
'SELECT * FROM songs WHERE id = ?',
id
);
if (!row) return null;
return { ...row, favorite: row.favorite === 1 };
}
export async function deleteSong(id: string): Promise<void> {
const db = await getDatabase();
const song = await getSongById(id);
if (song) {
await deleteFile(song.filePath);
if (song.coverArtPath) {
await deleteFile(song.coverArtPath);
}
}
await db.runAsync('DELETE FROM songs WHERE id = ?', id);
}
export async function toggleFavorite(id: string): Promise<boolean> {
const db = await getDatabase();
const song = await getSongById(id);
if (!song) return false;
const newFav = !song.favorite;
await db.runAsync('UPDATE songs SET favorite = ? WHERE id = ?', newFav ? 1 : 0, id);
return newFav;
}
export async function updatePlayStats(id: string): Promise<void> {
const db = await getDatabase();
await db.runAsync(
'UPDATE songs SET playCount = playCount + 1, lastPlayedAt = ? WHERE id = ?',
new Date().toISOString(),
id
);
}
export async function updateSongDuration(id: string, duration: number): Promise<void> {
const db = await getDatabase();
await db.runAsync('UPDATE songs SET duration = ? WHERE id = ?', duration, id);
}
export async function getAlbums(): Promise<Album[]> {
const db = await getDatabase();
return db.getAllAsync<Album>(`
SELECT
album AS name,
COALESCE(albumArtist, artist) AS artist,
year,
(SELECT coverArtPath FROM songs s2 WHERE s2.album = songs.album AND s2.coverArtPath IS NOT NULL LIMIT 1) AS coverArtPath,
COUNT(*) AS songCount
FROM songs
WHERE album IS NOT NULL AND album != ''
GROUP BY album
ORDER BY album ASC
`);
}
export async function getSongsByAlbum(albumName: string): Promise<Song[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<Song & { favorite: number }>(
'SELECT * FROM songs WHERE album = ? ORDER BY discNumber ASC, trackNumber ASC, title ASC',
albumName
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function getArtists(): Promise<Artist[]> {
const db = await getDatabase();
return db.getAllAsync<Artist>(`
SELECT
artist AS name,
COUNT(*) AS songCount,
COUNT(DISTINCT album) AS albumCount
FROM songs
WHERE artist IS NOT NULL AND artist != ''
GROUP BY artist
ORDER BY artist ASC
`);
}
export async function getSongsByArtist(artistName: string): Promise<Song[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<Song & { favorite: number }>(
'SELECT * FROM songs WHERE artist = ? ORDER BY album ASC, trackNumber ASC, title ASC',
artistName
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function getGenres(): Promise<Genre[]> {
const db = await getDatabase();
return db.getAllAsync<Genre>(`
SELECT
genre AS name,
COUNT(*) AS songCount
FROM songs
WHERE genre IS NOT NULL AND genre != ''
GROUP BY genre
ORDER BY genre ASC
`);
}
export async function getSongsByGenre(genreName: string): Promise<Song[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<Song & { favorite: number }>(
'SELECT * FROM songs WHERE genre = ? ORDER BY artist ASC, album ASC, trackNumber ASC',
genreName
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function searchSongs(query: string): Promise<Song[]> {
const db = await getDatabase();
const q = `%${query}%`;
const rows = await db.getAllAsync<Song & { favorite: number }>(
'SELECT * FROM songs WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? ORDER BY title ASC LIMIT 50',
q,
q,
q
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function getSongCount(): Promise<number> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ count: number }>('SELECT COUNT(*) as count FROM songs');
return row?.count ?? 0;
}

View file

@ -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<Playlist> {
const db = await getDatabase();
const now = new Date().toISOString();
const playlist: Playlist = {
id: uuidv4(),
name,
description: description || null,
coverArtPath: null,
createdAt: now,
updatedAt: now,
};
await db.runAsync(
'INSERT INTO playlists (id, name, description, coverArtPath, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)',
playlist.id,
playlist.name,
playlist.description,
playlist.coverArtPath,
playlist.createdAt,
playlist.updatedAt
);
return playlist;
}
export async function getAllPlaylists(): Promise<Playlist[]> {
const db = await getDatabase();
return db.getAllAsync<Playlist>('SELECT * FROM playlists ORDER BY updatedAt DESC');
}
export async function getPlaylistById(id: string): Promise<Playlist | null> {
const db = await getDatabase();
return db.getFirstAsync<Playlist>('SELECT * FROM playlists WHERE id = ?', id);
}
export async function updatePlaylist(
id: string,
updates: { name?: string; description?: string }
): Promise<void> {
const db = await getDatabase();
const sets: string[] = ['updatedAt = ?'];
const values: (string | null)[] = [new Date().toISOString()];
if (updates.name !== undefined) {
sets.push('name = ?');
values.push(updates.name);
}
if (updates.description !== undefined) {
sets.push('description = ?');
values.push(updates.description);
}
values.push(id);
await db.runAsync(`UPDATE playlists SET ${sets.join(', ')} WHERE id = ?`, ...values);
}
export async function deletePlaylist(id: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM playlists WHERE id = ?', id);
}
export async function addSongToPlaylist(playlistId: string, songId: string): Promise<void> {
const db = await getDatabase();
const maxOrder = await db.getFirstAsync<{ maxOrder: number | null }>(
'SELECT MAX(sortOrder) as maxOrder FROM playlist_songs WHERE playlistId = ?',
playlistId
);
const sortOrder = (maxOrder?.maxOrder ?? -1) + 1;
await db.runAsync(
'INSERT INTO playlist_songs (id, playlistId, songId, sortOrder, addedAt) VALUES (?, ?, ?, ?, ?)',
uuidv4(),
playlistId,
songId,
sortOrder,
new Date().toISOString()
);
await db.runAsync(
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
new Date().toISOString(),
playlistId
);
}
export async function removeSongFromPlaylist(playlistId: string, songId: string): Promise<void> {
const db = await getDatabase();
await db.runAsync(
'DELETE FROM playlist_songs WHERE playlistId = ? AND songId = ?',
playlistId,
songId
);
await db.runAsync(
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
new Date().toISOString(),
playlistId
);
}
export async function getPlaylistSongs(playlistId: string): Promise<Song[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<Song & { favorite: number }>(
`SELECT s.* FROM songs s
INNER JOIN playlist_songs ps ON s.id = ps.songId
WHERE ps.playlistId = ?
ORDER BY ps.sortOrder ASC`,
playlistId
);
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
}
export async function getPlaylistSongCount(playlistId: string): Promise<number> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM playlist_songs WHERE playlistId = ?',
playlistId
);
return row?.count ?? 0;
}

View file

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

View file

@ -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<void>;
loadAlbums: () => Promise<void>;
loadArtists: () => Promise<void>;
loadGenres: () => Promise<void>;
loadAll: () => Promise<void>;
toggleFavorite: (id: string) => Promise<void>;
}
export const useLibraryStore = create<LibraryState>((set, get) => ({
songs: [],
albums: [],
artists: [],
genres: [],
activeTab: 'songs',
sortField: 'title',
sortDirection: 'asc',
isLoading: false,
songCount: 0,
setActiveTab: (tab) => set({ activeTab: tab }),
setSortField: (field) => {
set({ sortField: field });
get().loadSongs();
},
setSortDirection: (dir) => {
set({ sortDirection: dir });
get().loadSongs();
},
loadSongs: async () => {
const { sortField, sortDirection } = get();
const songs = await libraryService.getAllSongs(
sortField,
sortDirection.toUpperCase() as 'ASC' | 'DESC'
);
set({ songs, songCount: songs.length });
},
loadAlbums: async () => {
const albums = await libraryService.getAlbums();
set({ albums });
},
loadArtists: async () => {
const artists = await libraryService.getArtists();
set({ artists });
},
loadGenres: async () => {
const genres = await libraryService.getGenres();
set({ genres });
},
loadAll: async () => {
set({ isLoading: true });
try {
await Promise.all([
get().loadSongs(),
get().loadAlbums(),
get().loadArtists(),
get().loadGenres(),
]);
} finally {
set({ isLoading: false });
}
},
toggleFavorite: async (id) => {
const newFav = await libraryService.toggleFavorite(id);
set((state) => ({
songs: state.songs.map((s) => (s.id === id ? { ...s, favorite: newFav } : s)),
}));
},
}));

View file

@ -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<PlayerState>((set, get) => ({
currentSong: null,
isPlaying: false,
position: 0,
duration: 0,
repeatMode: 'off',
shuffleMode: 'off',
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
playSong: (song, queue, startIndex) => {
let queueState: QueueState;
if (queue && queue.length > 0) {
const idx = startIndex ?? queue.findIndex((s) => s.id === song.id);
queueState = createQueue(queue, Math.max(0, idx));
if (get().shuffleMode === 'on') {
queueState = shuffleQueue(queueState);
}
} else {
queueState = createQueue([song], 0);
}
set({ currentSong: song, isPlaying: true, position: 0, duration: 0, queueState });
},
setPlaying: (playing) => set({ isPlaying: playing }),
setPosition: (position) => set({ position }),
setDuration: (duration) => set({ duration }),
toggleRepeat: () => {
const modes: RepeatMode[] = ['off', 'all', 'one'];
const current = modes.indexOf(get().repeatMode);
set({ repeatMode: modes[(current + 1) % modes.length] });
},
toggleShuffle: () => {
const { shuffleMode, queueState } = get();
if (shuffleMode === 'off') {
set({
shuffleMode: 'on',
queueState: shuffleQueue(queueState),
});
} else {
set({
shuffleMode: 'off',
queueState: unshuffleQueue(queueState),
});
}
},
nextSong: () => {
const { queueState, repeatMode } = get();
const nextIdx = getNextIndex(queueState, repeatMode);
if (nextIdx === null) {
set({ isPlaying: false });
return null;
}
const song = queueState.queue[nextIdx];
set({
currentSong: song,
position: 0,
duration: 0,
isPlaying: true,
queueState: { ...queueState, currentIndex: nextIdx },
});
return song;
},
previousSong: () => {
const { queueState, repeatMode, position } = get();
// If more than 3 seconds in, restart current song
if (position > 3) {
set({ position: 0 });
return get().currentSong;
}
const prevIdx = getPreviousIndex(queueState, repeatMode);
if (prevIdx === null) {
set({ position: 0 });
return get().currentSong;
}
const song = queueState.queue[prevIdx];
set({
currentSong: song,
position: 0,
duration: 0,
isPlaying: true,
queueState: { ...queueState, currentIndex: prevIdx },
});
return song;
},
getQueue: () => get().queueState.queue,
clearQueue: () => {
set({
currentSong: null,
isPlaying: false,
position: 0,
duration: 0,
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
});
},
}));

View file

@ -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<void>;
createPlaylist: (name: string, description?: string) => Promise<Playlist>;
deletePlaylist: (id: string) => Promise<void>;
updatePlaylist: (id: string, updates: { name?: string; description?: string }) => Promise<void>;
}
export const usePlaylistStore = create<PlaylistState>((set, get) => ({
playlists: [],
isLoading: false,
loadPlaylists: async () => {
set({ isLoading: true });
try {
const playlists = await playlistService.getAllPlaylists();
set({ playlists });
} finally {
set({ isLoading: false });
}
},
createPlaylist: async (name, description) => {
const playlist = await playlistService.createPlaylist(name, description);
set((state) => ({ playlists: [playlist, ...state.playlists] }));
return playlist;
},
deletePlaylist: async (id) => {
await playlistService.deletePlaylist(id);
set((state) => ({ playlists: state.playlists.filter((p) => p.id !== id) }));
},
updatePlaylist: async (id, updates) => {
await playlistService.updatePlaylist(id, updates);
await get().loadPlaylists();
},
}));

View file

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

View file

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

View file

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

View file

@ -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<ThemeContextType>({
isDarkMode: false,
themeVariant: 'classic',
colors: getThemeColors('classic', false),
toggleTheme: () => {},
setDarkMode: () => {},
setThemeVariant: () => {},
});
export const useTheme = () => useContext(ThemeContext);
const THEME_PREFERENCE_KEY = '@mukke_theme_preference';
const THEME_VARIANT_KEY = '@mukke_theme_variant';
type ThemeProviderProps = {
children: React.ReactNode | ((themeProps: ThemeContextType) => React.ReactNode);
};
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const systemColorScheme = useColorScheme();
const [isDarkMode, setIsDarkMode] = useState(false);
const [themeVariant, setThemeVariantState] = useState<ThemeVariant>('classic');
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const loadThemePreferences = async () => {
try {
const [savedPreference, savedVariant] = await Promise.all([
AsyncStorage.getItem(THEME_PREFERENCE_KEY),
AsyncStorage.getItem(THEME_VARIANT_KEY),
]);
if (savedPreference !== null) {
setIsDarkMode(savedPreference === 'dark');
} else {
setIsDarkMode(systemColorScheme === 'dark');
}
if (savedVariant !== null && ['classic', 'ocean', 'sunset'].includes(savedVariant)) {
setThemeVariantState(savedVariant as ThemeVariant);
}
} catch (error) {
console.error('Failed to load theme preferences', error);
} finally {
setIsLoaded(true);
}
};
loadThemePreferences();
}, [systemColorScheme]);
useEffect(() => {
if (isLoaded) {
AsyncStorage.setItem(THEME_PREFERENCE_KEY, isDarkMode ? 'dark' : 'light').catch((error) =>
console.error('Failed to save theme preference', error)
);
}
}, [isDarkMode, isLoaded]);
useEffect(() => {
if (isLoaded) {
AsyncStorage.setItem(THEME_VARIANT_KEY, themeVariant).catch((error) =>
console.error('Failed to save theme variant', error)
);
}
}, [themeVariant, isLoaded]);
const toggleTheme = () => {
setIsDarkMode((prev) => !prev);
};
const setDarkMode = (isDark: boolean) => {
setIsDarkMode(isDark);
};
const setThemeVariant = (variant: ThemeVariant) => {
setThemeVariantState(variant);
};
const colors = getThemeColors(themeVariant, isDarkMode);
const themeContextValue = {
isDarkMode,
themeVariant,
colors,
toggleTheme,
setDarkMode,
setThemeVariant,
};
return (
<ThemeContext.Provider value={themeContextValue}>
{typeof children === 'function' ? children(themeContextValue) : children}
</ThemeContext.Provider>
);
};

9
apps/mukke/package.json Normal file
View file

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

View file

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

View file

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

View file

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