diff --git a/apps/matrix/apps/mobile/.gitignore b/apps/matrix/apps/mobile/.gitignore
index 9391d962d..526492a93 100644
--- a/apps/matrix/apps/mobile/.gitignore
+++ b/apps/matrix/apps/mobile/.gitignore
@@ -13,3 +13,9 @@ android/
web-build/
.env
.env.local
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+expo-env.d.ts
+# @end expo-cli
\ No newline at end of file
diff --git a/apps/matrix/apps/mobile/app/(app)/dms.tsx b/apps/matrix/apps/mobile/app/(app)/dms.tsx
index 1a71f5ced..7ca1ad97c 100644
--- a/apps/matrix/apps/mobile/app/(app)/dms.tsx
+++ b/apps/matrix/apps/mobile/app/(app)/dms.tsx
@@ -21,7 +21,7 @@ export default function DMsScreen() {
const dmInvites = useMemo(
() => rooms.filter((r) => r.membership === 'invite' && r.isDirect),
- [rooms],
+ [rooms]
);
const handleRoomPress = (roomId: string) => {
@@ -37,9 +37,7 @@ export default function DMsScreen() {
Direct Messages
router.push('/room/new')}
- className={({ pressed }) =>
- `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
- }
+ className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
>
@@ -69,7 +67,7 @@ export default function DMsScreen() {
renderItem={({ item }) => (
handleRoomPress(item.id)} />
)}
- contentContainerClassName="pb-4"
+ contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
dmInvites.length > 0 ? (
diff --git a/apps/matrix/apps/mobile/app/(app)/index.tsx b/apps/matrix/apps/mobile/app/(app)/index.tsx
index 08a8d57ab..cb30e8bbb 100644
--- a/apps/matrix/apps/mobile/app/(app)/index.tsx
+++ b/apps/matrix/apps/mobile/app/(app)/index.tsx
@@ -22,7 +22,7 @@ export default function ChatsScreen() {
// Pending invites
const invites = useMemo(
() => rooms.filter((r) => r.membership === 'invite' && !r.isDirect),
- [rooms],
+ [rooms]
);
const handleRoomPress = (roomId: string) => {
@@ -40,17 +40,13 @@ export default function ChatsScreen() {
router.push('/search')}
- className={({ pressed }) =>
- `w-9 h-9 bg-surface border border-border rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
- }
+ className="w-9 h-9 bg-surface border border-border rounded-full items-center justify-center active:opacity-70"
>
router.push('/room/new')}
- className={({ pressed }) =>
- `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
- }
+ className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
>
@@ -84,7 +80,7 @@ export default function ChatsScreen() {
renderItem={({ item }) => (
handleRoomPress(item.id)} />
)}
- contentContainerClassName="pb-4"
+ contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
invites.length > 0 ? (
diff --git a/apps/matrix/apps/mobile/app/(app)/invites.tsx b/apps/matrix/apps/mobile/app/(app)/invites.tsx
index c42fc3c5a..7307f3836 100644
--- a/apps/matrix/apps/mobile/app/(app)/invites.tsx
+++ b/apps/matrix/apps/mobile/app/(app)/invites.tsx
@@ -4,14 +4,26 @@ import { Image } from 'expo-image';
import { useMatrixStore } from '~/src/matrix/store';
import type { SimpleRoom } from '~/src/matrix/types';
-function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: () => void; onDecline: () => void }) {
+function InviteCard({
+ room,
+ onAccept,
+ onDecline,
+}: {
+ room: SimpleRoom;
+ onAccept: () => void;
+ onDecline: () => void;
+}) {
return (
{/* Avatar */}
{room.avatar ? (
-
+
) : (
{(room.name ?? '?')[0].toUpperCase()}
@@ -30,17 +42,15 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
)}
{room.inviter && (
-
- Invited by {room.inviter}
-
+ Invited by {room.inviter}
)}
- {room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
+ {room.isDirect
+ ? 'Direct message'
+ : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
- {room.isEncrypted && (
- Β· π Encrypted
- )}
+ {room.isEncrypted && Β· π Encrypted}
@@ -49,15 +59,13 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
- `flex-1 py-3 items-center border-r border-border ${pressed ? 'bg-surface' : ''}`
- }
+ className="flex-1 py-3 items-center border-r border-border active:bg-surface"
>
Decline
`flex-1 py-3 items-center ${pressed ? 'bg-primary/80' : 'bg-primary'}`}
+ className="flex-1 py-3 items-center bg-primary active:bg-primary/80"
>
Accept
@@ -114,7 +122,7 @@ export default function InvitesScreen() {
onDecline={() => handleDecline(item.id, item.name)}
/>
)}
- contentContainerClassName="pt-2 pb-6"
+ contentContainerStyle={{ paddingTop: 8, paddingBottom: 24 }}
ListEmptyComponent={
βοΈ
diff --git a/apps/matrix/apps/mobile/app/(app)/settings.tsx b/apps/matrix/apps/mobile/app/(app)/settings.tsx
index 973b4948a..2f2dc515b 100644
--- a/apps/matrix/apps/mobile/app/(app)/settings.tsx
+++ b/apps/matrix/apps/mobile/app/(app)/settings.tsx
@@ -41,17 +41,19 @@ export default function SettingsScreen() {
const [uploadingAvatar, setUploadingAvatar] = useState(false);
// Get current profile from client
- const profileInfo = client ? (() => {
- try {
- const user = client.getUser(userId);
- return {
- displayName: user?.displayName ?? userId.split(':')[0].slice(1),
- avatarUrl: user?.avatarUrl ?? undefined,
- };
- } catch {
- return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined };
- }
- })() : { displayName: '', avatarUrl: undefined };
+ const profileInfo = client
+ ? (() => {
+ try {
+ const user = client.getUser(userId);
+ return {
+ displayName: user?.displayName ?? userId.split(':')[0].slice(1),
+ avatarUrl: user?.avatarUrl ?? undefined,
+ };
+ } catch {
+ return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined };
+ }
+ })()
+ : { displayName: '', avatarUrl: undefined };
const handleEditName = () => {
setNewDisplayName(profileInfo.displayName);
@@ -107,15 +109,11 @@ export default function SettingsScreen() {
Settings
-
+
{/* Profile card */}
{/* Avatar */}
-
+
- Connection
+
+ Connection
+
Homeserver
- {homeserver || 'β'}
+
+ {homeserver || 'β'}
+
Sync status
-
- {syncState.toLowerCase()}
+
+
+ {syncState.toLowerCase()}
+
@@ -189,16 +195,19 @@ export default function SettingsScreen() {
{/* Sign out */}
- `bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center ${pressed ? 'opacity-60' : ''}`
- }
+ className="bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center active:opacity-60"
>
Sign out
{/* Edit display name modal */}
- setEditingName(false)}>
+ setEditingName(false)}
+ >
@@ -218,9 +227,7 @@ export default function SettingsScreen() {
- `bg-primary rounded-xl py-3 items-center ${pressed || savingName || !newDisplayName.trim() ? 'opacity-60' : ''}`
- }
+ className="bg-primary rounded-xl py-3 items-center active:opacity-60"
>
{savingName ? (
diff --git a/apps/matrix/apps/mobile/app/(auth)/login.tsx b/apps/matrix/apps/mobile/app/(auth)/login.tsx
index 9378ef684..252aaa79e 100644
--- a/apps/matrix/apps/mobile/app/(auth)/login.tsx
+++ b/apps/matrix/apps/mobile/app/(auth)/login.tsx
@@ -92,7 +92,7 @@ export default function LoginScreen() {
base,
response.access_token,
response.user_id,
- response.device_id,
+ response.device_id
);
if (loginResult.success && loginResult.credentials) {
await initialize(loginResult.credentials);
@@ -112,7 +112,7 @@ export default function LoginScreen() {
className="flex-1"
>
{/* Logo */}
@@ -134,7 +134,10 @@ export default function LoginScreen() {
{ setHomeserver(v); setServerOk(null); }}
+ onChangeText={(v) => {
+ setHomeserver(v);
+ setServerOk(null);
+ }}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -187,9 +190,7 @@ export default function LoginScreen() {
- `bg-primary rounded-xl py-4 items-center mt-1 ${pressed || loading ? 'opacity-70' : ''}`
- }
+ className="bg-primary rounded-xl py-4 items-center mt-1 active:opacity-70"
>
{loading ? (
@@ -209,18 +210,12 @@ export default function LoginScreen() {
- `bg-surface border border-border rounded-xl py-4 items-center ${
- pressed || ssoLoading ? 'opacity-70' : ''
- }`
- }
+ className="bg-surface border border-border rounded-xl py-4 items-center active:opacity-70"
>
{ssoLoading ? (
) : (
-
- Sign in with SSO
-
+ Sign in with SSO
)}
diff --git a/apps/matrix/apps/mobile/app/room/[id].tsx b/apps/matrix/apps/mobile/app/room/[id].tsx
index a37abe9e1..35c10e986 100644
--- a/apps/matrix/apps/mobile/app/room/[id].tsx
+++ b/apps/matrix/apps/mobile/app/room/[id].tsx
@@ -36,10 +36,13 @@ type ListItem =
| { type: 'unread'; key: string };
function isSameDay(a: number, b: number) {
- const da = new Date(a), db = new Date(b);
- return da.getFullYear() === db.getFullYear() &&
+ const da = new Date(a),
+ db = new Date(b);
+ return (
+ da.getFullYear() === db.getFullYear() &&
da.getMonth() === db.getMonth() &&
- da.getDate() === db.getDate();
+ da.getDate() === db.getDate()
+ );
}
function buildListItems(messages: SimpleMessage[], firstUnreadEventId: string | null): ListItem[] {
@@ -65,11 +68,15 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
<>
setShowProfile(true)}
- className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
+ className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
>
{member.avatarUrl ? (
-
+
) : (
{member.displayName[0]?.toUpperCase() ?? '?'}
@@ -78,7 +85,9 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
{member.displayName}
- {member.userId}
+
+ {member.userId}
+
{member.powerLevel >= 100 && (
@@ -93,7 +102,10 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
{ setShowProfile(false); onClose(); }}
+ onClose={() => {
+ setShowProfile(false);
+ onClose();
+ }}
/>
>
);
@@ -116,10 +128,25 @@ export default function RoomScreen() {
const [forwardSearch, setForwardSearch] = useState('');
const {
- rooms, messages, firstUnreadEventId, typingUsers, roomMembers, client, credentials,
- selectRoom, loadRoomMembers, sendMessage, editMessage,
- sendReaction, redactMessage, sendTyping,
- sendImage, sendFile, sendVoice, forwardMessage, leaveRoom,
+ rooms,
+ messages,
+ firstUnreadEventId,
+ typingUsers,
+ roomMembers,
+ client,
+ credentials,
+ selectRoom,
+ loadRoomMembers,
+ sendMessage,
+ editMessage,
+ sendReaction,
+ redactMessage,
+ sendTyping,
+ sendImage,
+ sendFile,
+ sendVoice,
+ forwardMessage,
+ leaveRoom,
} = useMatrixStore();
const room = rooms.find((r) => r.id === id);
@@ -130,9 +157,14 @@ export default function RoomScreen() {
return (matrixRoom?.getMember(userId)?.powerLevel ?? 0) >= 100;
}, [client, id]);
- useEffect(() => { if (id) selectRoom(id); }, [id]);
+ useEffect(() => {
+ if (id) selectRoom(id);
+ }, [id]);
- const listItems = useMemo(() => buildListItems(messages, firstUnreadEventId), [messages, firstUnreadEventId]);
+ const listItems = useMemo(
+ () => buildListItems(messages, firstUnreadEventId),
+ [messages, firstUnreadEventId]
+ );
// Scroll to first unread message on initial load
useEffect(() => {
@@ -150,8 +182,11 @@ export default function RoomScreen() {
const matrixRoom = client.getRoom(id);
if (!matrixRoom) return;
setLoadingMore(true);
- try { await client.scrollback(matrixRoom, 30); }
- finally { setLoadingMore(false); }
+ try {
+ await client.scrollback(matrixRoom, 30);
+ } finally {
+ setLoadingMore(false);
+ }
};
const handleRoomOptions = () => {
@@ -162,15 +197,33 @@ export default function RoomScreen() {
ActionSheetIOS.showActionSheetWithOptions(
{ options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex },
(index) => {
- if (index === 1) { loadRoomMembers(id!); setShowMembers(true); }
- if (isAdmin && index === 2) { router.push({ pathname: '/room/settings', params: { id } }); }
+ if (index === 1) {
+ loadRoomMembers(id!);
+ setShowMembers(true);
+ }
+ if (isAdmin && index === 2) {
+ router.push({ pathname: '/room/settings', params: { id } });
+ }
if (index === options.length - 1) handleLeave();
- },
+ }
);
} else {
Alert.alert(room?.name ?? 'Room', undefined, [
- { text: 'Members', onPress: () => { loadRoomMembers(id!); setShowMembers(true); } },
- ...(isAdmin ? [{ text: 'Room settings', onPress: () => router.push({ pathname: '/room/settings', params: { id } }) }] : []),
+ {
+ text: 'Members',
+ onPress: () => {
+ loadRoomMembers(id!);
+ setShowMembers(true);
+ },
+ },
+ ...(isAdmin
+ ? [
+ {
+ text: 'Room settings',
+ onPress: () => router.push({ pathname: '/room/settings', params: { id } }),
+ },
+ ]
+ : []),
{ text: 'Leave room', style: 'destructive' as const, onPress: handleLeave },
{ text: 'Cancel', style: 'cancel' as const },
]);
@@ -199,7 +252,7 @@ export default function RoomScreen() {
if (index === 1) pickImage('library');
if (index === 2) pickImage('camera');
if (index === 3) pickDocument();
- },
+ }
);
} else {
Alert.alert('Attach', undefined, [
@@ -212,17 +265,26 @@ export default function RoomScreen() {
};
const pickImage = async (source: 'library' | 'camera') => {
- const fn = source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
+ const fn =
+ source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
const result = await fn({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.85 });
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
const filename = asset.fileName ?? `image_${Date.now()}.jpg`;
setUploading(true);
try {
- await sendImage(asset.uri, filename, asset.mimeType ?? getMimetypeFromFilename(filename), asset.width, asset.height);
+ await sendImage(
+ asset.uri,
+ filename,
+ asset.mimeType ?? getMimetypeFromFilename(filename),
+ asset.width,
+ asset.height
+ );
} catch (err) {
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
- } finally { setUploading(false); }
+ } finally {
+ setUploading(false);
+ }
};
const pickDocument = async () => {
@@ -234,7 +296,9 @@ export default function RoomScreen() {
await sendFile(asset.uri, asset.name, asset.mimeType ?? getMimetypeFromFilename(asset.name));
} catch (err) {
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
- } finally { setUploading(false); }
+ } finally {
+ setUploading(false);
+ }
};
const handleForward = useCallback((msg: SimpleMessage) => {
@@ -242,28 +306,37 @@ export default function RoomScreen() {
setForwardSearch('');
}, []);
- const handleForwardToRoom = useCallback(async (targetRoom: SimpleRoom) => {
- if (!forwardingMessage) return;
- try {
- await forwardMessage(forwardingMessage.id, targetRoom.id);
- setForwardingMessage(null);
- } catch (err) {
- Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error');
- }
- }, [forwardingMessage, forwardMessage]);
+ const handleForwardToRoom = useCallback(
+ async (targetRoom: SimpleRoom) => {
+ if (!forwardingMessage) return;
+ try {
+ await forwardMessage(forwardingMessage.id, targetRoom.id);
+ setForwardingMessage(null);
+ } catch (err) {
+ Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error');
+ }
+ },
+ [forwardingMessage, forwardMessage]
+ );
const handleEdit = useCallback((msg: SimpleMessage) => {
setReplyTo(null);
setEditingMessage(msg);
}, []);
- const handleSend = useCallback(async (body: string, replyToEventId?: string) => {
- await sendMessage(body, replyToEventId);
- }, [sendMessage]);
+ const handleSend = useCallback(
+ async (body: string, replyToEventId?: string) => {
+ await sendMessage(body, replyToEventId);
+ },
+ [sendMessage]
+ );
- const handleEditSave = useCallback(async (eventId: string, newBody: string) => {
- await editMessage(eventId, newBody);
- }, [editMessage]);
+ const handleEditSave = useCallback(
+ async (eventId: string, newBody: string) => {
+ await editMessage(eventId, newBody);
+ },
+ [editMessage]
+ );
const renderItem = ({ item, index }: { item: ListItem; index: number }) => {
if (item.type === 'date') return ;
@@ -273,7 +346,10 @@ export default function RoomScreen() {
{ setEditingMessage(null); setReplyTo(msg); }}
+ onReply={(msg) => {
+ setEditingMessage(null);
+ setReplyTo(msg);
+ }}
onEdit={handleEdit}
onReact={sendReaction}
onDelete={redactMessage}
@@ -288,7 +364,7 @@ export default function RoomScreen() {
{/* Header */}
- router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+ router.back()} className="p-1 active:opacity-50">
@@ -299,14 +375,16 @@ export default function RoomScreen() {
{room?.isEncrypted && }
{room?.topic ? (
- {room.topic}
+
+ {room.topic}
+
) : room?.memberCount != null ? (
{room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
) : null}
- `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
@@ -321,9 +399,9 @@ export default function RoomScreen() {
item.type === 'message' ? item.data.id : item.key}
+ keyExtractor={(item) => (item.type === 'message' ? item.data.id : item.key)}
renderItem={renderItem}
- contentContainerClassName="px-0 py-2"
+ contentContainerStyle={{ paddingHorizontal: 0, paddingVertical: 8 }}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.15}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
@@ -342,9 +420,14 @@ export default function RoomScreen() {
{
setUploading(true);
- try { await sendVoice(uri, durationMs); }
- catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); }
- finally { setUploading(false); setShowVoiceRecorder(false); }
+ try {
+ await sendVoice(uri, durationMs);
+ } catch (err) {
+ Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setUploading(false);
+ setShowVoiceRecorder(false);
+ }
}}
onCancel={() => setShowVoiceRecorder(false)}
/>
@@ -363,22 +446,33 @@ export default function RoomScreen() {
)}
{/* Members modal */}
- setShowMembers(false)}>
+ setShowMembers(false)}
+ >
Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
- setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+ setShowMembers(false)} className="p-1 active:opacity-50">
-
+
{roomMembers.length === 0 ? (
-
+
+
+
) : (
roomMembers.map((member) => (
- setShowMembers(false)} />
+ setShowMembers(false)}
+ />
))
)}
@@ -390,11 +484,16 @@ export default function RoomScreen() {
setProfileUserId(null)} />
{/* Forward message modal */}
- setForwardingMessage(null)}>
+ setForwardingMessage(null)}
+ >
Forward to...
- setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+ setForwardingMessage(null)} className="p-1 active:opacity-50">
@@ -411,21 +510,29 @@ export default function RoomScreen() {
{forwardingMessage && (
Message:
- {forwardingMessage.body}
+
+ {forwardingMessage.body}
+
)}
-
+
{rooms
- .filter((r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase()))
+ .filter(
+ (r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase())
+ )
.map((r) => (
handleForwardToRoom(r)}
- className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
+ className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
>
{r.avatar ? (
-
+
) : (
{r.name[0]?.toUpperCase() ?? '?'}
@@ -433,8 +540,12 @@ export default function RoomScreen() {
)}
- {r.name}
- {r.isDirect && Direct message}
+
+ {r.name}
+
+ {r.isDirect && (
+ Direct message
+ )}
))}
diff --git a/apps/matrix/apps/mobile/app/room/new.tsx b/apps/matrix/apps/mobile/app/room/new.tsx
index 20982d85f..c2040a9f0 100644
--- a/apps/matrix/apps/mobile/app/room/new.tsx
+++ b/apps/matrix/apps/mobile/app/room/new.tsx
@@ -83,10 +83,7 @@ export default function NewRoomScreen() {
{/* Header */}
- router.back()}
- className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
- >
+ router.back()} className="p-1 active:opacity-50">
New conversation
@@ -97,7 +94,7 @@ export default function NewRoomScreen() {
className="flex-1"
>
{/* Mode toggle */}
@@ -195,9 +192,7 @@ export default function NewRoomScreen() {
- `bg-primary rounded-xl py-4 items-center ${pressed || loading ? 'opacity-70' : ''}`
- }
+ className="bg-primary rounded-xl py-4 items-center active:opacity-70"
>
{loading ? (
diff --git a/apps/matrix/apps/mobile/app/room/settings.tsx b/apps/matrix/apps/mobile/app/room/settings.tsx
index 631fb9a5a..afd30e9e2 100644
--- a/apps/matrix/apps/mobile/app/room/settings.tsx
+++ b/apps/matrix/apps/mobile/app/room/settings.tsx
@@ -1,5 +1,13 @@
import { useState, useEffect } from 'react';
-import { View, Text, TextInput, Pressable, ScrollView, Alert, ActivityIndicator } from 'react-native';
+import {
+ View,
+ Text,
+ TextInput,
+ Pressable,
+ ScrollView,
+ Alert,
+ ActivityIndicator,
+} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { ArrowLeft, Camera } from 'phosphor-react-native';
@@ -46,7 +54,9 @@ export default function RoomSettingsScreen() {
const uploaded = await uploadMedia(client, asset.uri, filename, 'image/jpeg');
setNewAvatarMxc(uploaded.mxcUrl);
setAvatarUri(
- credentials ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) : asset.uri,
+ credentials
+ ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri)
+ : asset.uri
);
} catch (err) {
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
@@ -80,36 +90,34 @@ export default function RoomSettingsScreen() {
};
const hasChanges =
- name.trim() !== room?.name ||
- topic.trim() !== (room?.topic ?? '') ||
- newAvatarMxc !== null;
+ name.trim() !== room?.name || topic.trim() !== (room?.topic ?? '') || newAvatarMxc !== null;
return (
{/* Header */}
- router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+ router.back()} className="p-1 active:opacity-50">
Room Settings
- `px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} ${pressed ? 'opacity-60' : ''}`
- }
+ className={`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
>
{saving ? (
) : (
-
+
Save
)}
-
+
{/* Avatar */}
@@ -117,7 +125,11 @@ export default function RoomSettingsScreen() {
{uploadingAvatar ? (
) : avatarUri ? (
-
+
) : (
{room?.name?.[0]?.toUpperCase() ?? '#'}
@@ -165,7 +177,9 @@ export default function RoomSettingsScreen() {
Room ID
- {id}
+
+ {id}
+
diff --git a/apps/matrix/apps/mobile/app/search.tsx b/apps/matrix/apps/mobile/app/search.tsx
index 3a05f90ed..483931045 100644
--- a/apps/matrix/apps/mobile/app/search.tsx
+++ b/apps/matrix/apps/mobile/app/search.tsx
@@ -1,13 +1,5 @@
import { useState, useCallback } from 'react';
-import {
- View,
- Text,
- TextInput,
- FlatList,
- Pressable,
- ActivityIndicator,
- Alert,
-} from 'react-native';
+import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { ArrowLeft, MagnifyingGlass, Lock, Users } from 'phosphor-react-native';
@@ -57,7 +49,7 @@ export default function SearchScreen() {
setLoading(false);
}
},
- [client, credentials],
+ [client, credentials]
);
const handleSearch = (text: string) => {
@@ -98,7 +90,11 @@ export default function SearchScreen() {
{/* Avatar */}
{item.avatar_url ? (
-
+
) : (
{initial}
)}
@@ -110,9 +106,7 @@ export default function SearchScreen() {
{name}
- {item.join_rule === 'public' ? null : (
-
- )}
+ {item.join_rule === 'public' ? null : }
{item.topic && (
@@ -129,9 +123,7 @@ export default function SearchScreen() {
handleJoin(item)}
disabled={isJoining}
- className={({ pressed }) =>
- `bg-primary rounded-lg px-3 py-1.5 shrink-0 ${pressed || isJoining ? 'opacity-60' : ''}`
- }
+ className="bg-primary rounded-lg px-3 py-1.5 shrink-0 active:opacity-60"
>
{isJoining ? (
@@ -147,10 +139,7 @@ export default function SearchScreen() {
{/* Header */}
- router.back()}
- className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
- >
+ router.back()} className="p-1 active:opacity-50">
Explore rooms
diff --git a/apps/matrix/apps/mobile/metro.config.js b/apps/matrix/apps/mobile/metro.config.js
index 84243860e..d285173b2 100644
--- a/apps/matrix/apps/mobile/metro.config.js
+++ b/apps/matrix/apps/mobile/metro.config.js
@@ -1,5 +1,9 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
+const path = require('path');
+
+// Monorepo root where hoisted node_modules live
+const monorepoRoot = path.resolve(__dirname, '../../../..');
const config = getDefaultConfig(__dirname);
@@ -11,12 +15,37 @@ config.resolver.extraNodeModules = {
stream: require.resolve('stream-browserify'),
};
-// Block matrix-sdk-crypto-wasm (uses import.meta, not compatible with Hermes)
+// In pnpm monorepos with node-linker=hoisted, pnpm may place a different version of
+// react-native-css-interop in the app's local node_modules vs the monorepo root.
+// This causes module duplication in the Metro bundle: the transformer's injectData()
+// writes styles to one module instance while the JSX runtime's getStyle() reads from
+// another (empty) instance, resulting in no styles being applied.
+//
+// Fix: intercept react-native-css-interop imports and resolve them from the monorepo
+// root node_modules, bypassing any local copy.
config.resolver.resolveRequest = (context, moduleName, platform) => {
+ // Block matrix-sdk-crypto-wasm (uses import.meta, not compatible with Hermes)
if (moduleName === '@matrix-org/matrix-sdk-crypto-wasm') {
return { type: 'empty' };
}
+
+ // Deduplicate react-native-css-interop by resolving from monorepo root
+ if (
+ moduleName === 'react-native-css-interop' ||
+ moduleName.startsWith('react-native-css-interop/')
+ ) {
+ return context.resolveRequest({ ...context, originDir: monorepoRoot }, moduleName, platform);
+ }
+
return context.resolveRequest(context, moduleName, platform);
};
-module.exports = withNativeWind(config, { input: './global.css' });
+// In a pnpm monorepo with node-linker=hoisted, the virtual module system used by
+// react-native-css-interop can fail because node_modules are at the monorepo root,
+// not inside the app directory. Using forceWriteFileSystem bypasses virtual modules
+// and writes CSS data directly to the cache files on disk, which Metro then reads
+// and the transformer wraps with injectData().
+module.exports = withNativeWind(config, {
+ input: './global.css',
+ forceWriteFileSystem: true,
+});
diff --git a/apps/matrix/apps/mobile/package.json b/apps/matrix/apps/mobile/package.json
index 7e9e0343c..c9a251046 100644
--- a/apps/matrix/apps/mobile/package.json
+++ b/apps/matrix/apps/mobile/package.json
@@ -38,12 +38,12 @@
"expo-system-ui": "~55.0.9",
"expo-web-browser": "~55.0.9",
"matrix-js-sdk": "^37.1.0",
- "nativewind": "~4.2.2",
+ "nativewind": "~4.2.3",
"phosphor-react-native": "^2.3.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
- "react-native-css-interop": "0.2.2",
+ "react-native-css-interop": "0.2.3",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "~4.2.1",
"react-native-safe-area-context": "~5.6.2",
diff --git a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx
index 829df5335..a44885ce2 100644
--- a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx
+++ b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx
@@ -38,18 +38,14 @@ export default function ImageViewer({ uri, onClose }: Props) {
- `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed ? 'opacity-60' : ''}`
- }
+ className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
>
- `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed || saving ? 'opacity-60' : ''}`
- }
+ className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
>
diff --git a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx
index 7f5d506cf..d7fbfe09a 100644
--- a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx
+++ b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx
@@ -66,7 +66,7 @@ function AvatarCircle({
);
if (!onPress) return inner;
return (
- `${pressed ? 'opacity-60' : ''}`}>
+
{inner}
);
@@ -113,17 +113,14 @@ function ReactionDetailsModal({
Reactions
- `p-1 ${pressed ? 'opacity-50' : ''}`}
- >
+
Done
{reactions.map((r) => (
))}
-
+
{selected?.users.map((userId) => (
diff --git a/apps/matrix/apps/mobile/src/components/MessageInput.tsx b/apps/matrix/apps/mobile/src/components/MessageInput.tsx
index 29bffa5a9..2d6cb023d 100644
--- a/apps/matrix/apps/mobile/src/components/MessageInput.tsx
+++ b/apps/matrix/apps/mobile/src/components/MessageInput.tsx
@@ -76,9 +76,13 @@ export default function MessageInput({
{/* Context banner: Reply or Edit */}
{(replyTo || isEditing) && (
-
+
-
+
{isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
@@ -87,7 +91,7 @@ export default function MessageInput({
`p-1 ${pressed ? 'opacity-50' : ''}`}
+ className="p-1 active:opacity-50"
>
@@ -99,9 +103,7 @@ export default function MessageInput({
{onAttach && !isEditing && (
- `w-10 h-10 items-center justify-center rounded-full ${pressed ? 'opacity-50' : ''}`
- }
+ className="w-10 h-10 items-center justify-center rounded-full active:opacity-50"
>
@@ -126,9 +128,7 @@ export default function MessageInput({
{showMic ? (
- `w-10 h-10 rounded-full items-center justify-center bg-surface border border-border ${pressed ? 'opacity-60' : ''}`
- }
+ className="w-10 h-10 rounded-full items-center justify-center bg-surface border border-border active:opacity-60"
>
@@ -136,13 +136,13 @@ export default function MessageInput({
- `w-10 h-10 rounded-full items-center justify-center ${
- canSend
- ? isEditing ? 'bg-yellow-500' : 'bg-primary'
- : 'bg-surface border border-border'
- } ${pressed ? 'opacity-60' : ''}`
- }
+ className={`w-10 h-10 rounded-full items-center justify-center ${
+ canSend
+ ? isEditing
+ ? 'bg-yellow-500'
+ : 'bg-primary'
+ : 'bg-surface border border-border'
+ } active:opacity-60`}
>
{isEditing ? (
diff --git a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx
index b03f1cc9e..df065d175 100644
--- a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx
+++ b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx
@@ -38,9 +38,7 @@ export default function RoomListItem({ room, onPress }: Props) {
return (
- `flex-row items-center px-4 py-3 gap-3 ${pressed ? 'bg-surface/60' : ''}`
- }
+ className="flex-row items-center px-4 py-3 gap-3 active:bg-surface/60"
>
{/* Avatar */}
@@ -80,8 +78,8 @@ export default function RoomListItem({ room, onPress }: Props) {
>
{room.lastMessage
? (room.lastMessageSender && !room.isDirect
- ? `${room.lastMessageSender.split(':')[0].slice(1)}: `
- : '') + room.lastMessage
+ ? `${room.lastMessageSender.split(':')[0].slice(1)}: `
+ : '') + room.lastMessage
: room.isEncrypted
? 'π Encrypted'
: 'No messages'}
@@ -95,7 +93,11 @@ export default function RoomListItem({ room, onPress }: Props) {
}`}
>
- {hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount}
+ {hasHighlight
+ ? room.highlightCount
+ : room.unreadCount > 99
+ ? '99+'
+ : room.unreadCount}
)}
diff --git a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
index 126e0e767..837a95f76 100644
--- a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
+++ b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
@@ -29,14 +29,15 @@ export default function UserProfileModal({ userId, onClose }: Props) {
setLoading(true);
setProfile(null);
- client.getProfileInfo(userId)
+ client
+ .getProfileInfo(userId)
.then((info) => {
const rawAvatar = info.avatar_url ?? null;
setProfile({
userId,
displayName: info.displayname ?? userId.split(':')[0].slice(1),
avatarUrl: rawAvatar
- ? resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined
+ ? (resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined)
: undefined,
});
})
@@ -50,9 +51,7 @@ export default function UserProfileModal({ userId, onClose }: Props) {
}, [userId]);
// Find an existing DM room with this user
- const existingDM = userId
- ? rooms.find((r) => r.isDirect && r.dmUserId === userId)
- : null;
+ const existingDM = userId ? rooms.find((r) => r.isDirect && r.dmUserId === userId) : null;
const handleStartDM = async () => {
if (!client || !userId || !credentials) return;
@@ -81,12 +80,7 @@ export default function UserProfileModal({ userId, onClose }: Props) {
const initial = profile?.displayName[0]?.toUpperCase() ?? '?';
return (
-
+
e.stopPropagation()}>
@@ -98,12 +92,20 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Close */}
- `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
-
+
{loading ? (
) : profile ? (
@@ -111,7 +113,11 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Avatar */}
{profile.avatarUrl ? (
-
+
) : (
{initial}
)}
@@ -119,17 +125,19 @@ export default function UserProfileModal({ userId, onClose }: Props) {
{/* Name */}
- {profile.displayName}
- {profile.userId}
+
+ {profile.displayName}
+
+
+ {profile.userId}
+
{/* Actions */}
{profile.userId !== credentials?.userId && (
- `flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 ${pressed ? 'opacity-70' : ''}`
- }
+ className="flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 active:opacity-70"
>
diff --git a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
index 85dba87bb..945b8a7f4 100644
--- a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
+++ b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
@@ -47,9 +47,7 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) {
- `w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`
- }
+ className={`w-8 h-8 rounded-full items-center justify-center active:opacity-60 ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
>
{status.isBuffering ? (
diff --git a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
index 511cf5ee8..6ed38c28a 100644
--- a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
+++ b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
@@ -94,9 +94,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
{/* Discard */}
- `w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`
- }
+ className="w-10 h-10 rounded-full bg-destructive/10 items-center justify-center active:opacity-60"
>
@@ -115,9 +113,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
- `w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} ${pressed || sending ? 'opacity-60' : ''}`
- }
+ className={`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
>
= 1 ? '#fff' : '#6b7280'} weight="fill" />
diff --git a/apps/mukke/CLAUDE.md b/apps/mukke/CLAUDE.md
new file mode 100644
index 000000000..b586e5bbe
--- /dev/null
+++ b/apps/mukke/CLAUDE.md
@@ -0,0 +1,60 @@
+# CLAUDE.md - Mukke
+
+Offline-first iOS Music Player. Songs aus iCloud/lokalen Dateien importieren, lokal auf dem GerΓ€t speichern, abspielen.
+
+## Project Structure
+
+```
+apps/mukke/
+βββ package.json # Orchestrator (name: mukke)
+βββ apps/
+β βββ mobile/ # @mukke/mobile (Expo SDK 54)
+β βββ app/ # Expo Router screens
+β β βββ (tabs)/ # 4 Tab-Screens (Bibliothek, Playlists, Suche, Settings)
+β β βββ player.tsx # Full-Screen Player (modal)
+β β βββ queue.tsx # Queue Ansicht (modal)
+β β βββ album/[id] # Album Detail
+β β βββ artist/[id] # Artist Detail
+β β βββ playlist/ # Playlist Detail + New
+β βββ components/ # UI components
+β βββ contexts/ # AudioContext (expo-audio)
+β βββ stores/ # Zustand stores (player, library, playlist)
+β βββ services/ # Business logic (DB, import, audio, library, playlist)
+β βββ utils/ # Theme system
+βββ packages/
+ βββ mukke-types/ # @mukke/types (shared interfaces)
+```
+
+## Commands
+
+```bash
+pnpm dev:mukke:mobile # Start Expo app
+```
+
+## Tech Stack
+
+- **Audio**: expo-audio (background via UIBackgroundModes: ["audio"])
+- **Import**: expo-document-picker (iCloud + lokale Dateien)
+- **Storage**: expo-file-system (documentDirectory)
+- **Metadata**: @missingcore/audio-metadata (ID3v2.3/v2.4)
+- **DB**: expo-sqlite (SQLite fΓΌr Songs, Playlists)
+- **State**: Zustand
+- **Navigation**: Expo Router + NativeTabs
+- **Styling**: NativeWind / Tailwind
+
+## Architecture
+
+- **No backend** - pure offline, local-only app
+- **SQLite** for structured data (songs, playlists, playlist_songs)
+- **Albums/Artists/Genres** derived from songs table via queries (no separate tables)
+- **File storage**: documentDirectory/music/ + documentDirectory/artwork/
+- **Audio playback**: expo-audio with background mode
+- **MiniPlayer**: persistent above tab bar
+
+## Import Flow
+
+1. User taps Import β expo-document-picker opens (iCloud + local)
+2. Files copied to documentDirectory/music/{uuid}.ext
+3. Metadata extracted via @missingcore/audio-metadata
+4. Cover art saved to documentDirectory/artwork/{uuid}.jpg
+5. Song entry created in SQLite
diff --git a/apps/mukke/apps/mobile/.gitignore b/apps/mukke/apps/mobile/.gitignore
new file mode 100644
index 000000000..5873d9abc
--- /dev/null
+++ b/apps/mukke/apps/mobile/.gitignore
@@ -0,0 +1,6 @@
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+expo-env.d.ts
+# @end expo-cli
\ No newline at end of file
diff --git a/apps/mukke/apps/mobile/app.json b/apps/mukke/apps/mobile/app.json
new file mode 100644
index 000000000..ec396f149
--- /dev/null
+++ b/apps/mukke/apps/mobile/app.json
@@ -0,0 +1,53 @@
+{
+ "expo": {
+ "name": "Mukke",
+ "slug": "mukke",
+ "version": "1.0.0",
+ "scheme": "mukke",
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/favicon.png"
+ },
+ "plugins": ["expo-router", "expo-sqlite"],
+ "experiments": {
+ "typedRoutes": true,
+ "tsconfigPaths": true
+ },
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "automatic",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "assetBundlePatterns": ["**/*"],
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.mana.mukke",
+ "infoPlist": {
+ "UIBackgroundModes": ["audio"]
+ },
+ "config": {
+ "usesNonExemptEncryption": false
+ }
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "com.mana.mukke"
+ },
+ "owner": "memoro",
+ "extra": {
+ "router": {
+ "origin": false
+ },
+ "eas": {
+ "projectId": "placeholder"
+ }
+ }
+ }
+}
diff --git a/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx b/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 000000000..b088e9b55
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,33 @@
+import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
+import { View } from 'react-native';
+
+import { MiniPlayer } from '~/components/MiniPlayer';
+
+export default function TabLayout() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/(tabs)/index.tsx b/apps/mukke/apps/mobile/app/(tabs)/index.tsx
new file mode 100644
index 000000000..6a099d7cf
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/(tabs)/index.tsx
@@ -0,0 +1,72 @@
+import { Stack } from 'expo-router';
+import { useEffect } from 'react';
+import { View } from 'react-native';
+
+import { AlbumGrid } from '~/components/AlbumGrid';
+import { ArtistList } from '~/components/ArtistList';
+import { GenreList } from '~/components/GenreList';
+import { ImportButton } from '~/components/ImportButton';
+import { SegmentedControl } from '~/components/SegmentedControl';
+import { SongList } from '~/components/SongList';
+import { SortMenu } from '~/components/SortMenu';
+import { useLibraryStore } from '~/stores/libraryStore';
+import type { LibraryTab } from '~/types';
+
+const SEGMENTS: { key: LibraryTab; label: string }[] = [
+ { key: 'songs', label: 'Songs' },
+ { key: 'albums', label: 'Alben' },
+ { key: 'artists', label: 'KΓΌnstler' },
+ { key: 'genres', label: 'Genres' },
+];
+
+export default function LibraryScreen() {
+ const {
+ songs,
+ albums,
+ artists,
+ genres,
+ activeTab,
+ sortField,
+ sortDirection,
+ setActiveTab,
+ setSortField,
+ setSortDirection,
+ loadAll,
+ } = useLibraryStore();
+
+ useEffect(() => {
+ loadAll();
+ }, []);
+
+ return (
+
+ (
+
+ {activeTab === 'songs' && (
+ {
+ setSortField(field);
+ setSortDirection(dir);
+ }}
+ />
+ )}
+
+
+ ),
+ }}
+ />
+
+
+
+ {activeTab === 'songs' && }
+ {activeTab === 'albums' && }
+ {activeTab === 'artists' && }
+ {activeTab === 'genres' && }
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx b/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx
new file mode 100644
index 000000000..04a7128a2
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx
@@ -0,0 +1,70 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Stack, useRouter } from 'expo-router';
+import { useEffect } from 'react';
+import { FlatList, Pressable, View, Text } from 'react-native';
+
+import { EmptyState } from '~/components/EmptyState';
+import { ListItem } from '~/components/ListItem';
+import { usePlaylistStore } from '~/stores/playlistStore';
+import { useTheme } from '~/utils/themeContext';
+
+export default function PlaylistsScreen() {
+ const { colors } = useTheme();
+ const router = useRouter();
+ const { playlists, loadPlaylists } = usePlaylistStore();
+
+ useEffect(() => {
+ loadPlaylists();
+ }, []);
+
+ return (
+
+ (
+ router.push('/playlist/new')} style={{ padding: 8 }}>
+
+
+ ),
+ }}
+ />
+
+ {playlists.length === 0 ? (
+
+ ) : (
+ item.id}
+ contentContainerStyle={{ paddingBottom: 100 }}
+ renderItem={({ item }) => (
+
+
+
+ }
+ onPress={() => router.push(`/playlist/${item.id}`)}
+ showChevron
+ />
+ )}
+ />
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/(tabs)/search.tsx b/apps/mukke/apps/mobile/app/(tabs)/search.tsx
new file mode 100644
index 000000000..d932d9730
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/(tabs)/search.tsx
@@ -0,0 +1,79 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Stack } from 'expo-router';
+import { useState, useCallback } from 'react';
+import { View, TextInput } from 'react-native';
+
+import { EmptyState } from '~/components/EmptyState';
+import { SongList } from '~/components/SongList';
+import { searchSongs } from '~/services/libraryService';
+import type { Song } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+export default function SearchScreen() {
+ const { colors } = useTheme();
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [hasSearched, setHasSearched] = useState(false);
+
+ const handleSearch = useCallback(async (text: string) => {
+ setQuery(text);
+ if (text.trim().length < 2) {
+ setResults([]);
+ setHasSearched(false);
+ return;
+ }
+ setHasSearched(true);
+ const songs = await searchSongs(text.trim());
+ setResults(songs);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ {!hasSearched ? (
+
+ ) : results.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/(tabs)/settings.tsx b/apps/mukke/apps/mobile/app/(tabs)/settings.tsx
new file mode 100644
index 000000000..d4d53a307
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/(tabs)/settings.tsx
@@ -0,0 +1,187 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Stack } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { View, Text, Switch, ScrollView, Alert, Pressable } from 'react-native';
+
+import { useTheme, type ThemeVariant } from '~/utils/themeContext';
+import { getStorageInfo, formatFileSize } from '~/services/fileService';
+import { getSongCount } from '~/services/libraryService';
+import { pickAndImportFiles } from '~/services/importService';
+import { useLibraryStore } from '~/stores/libraryStore';
+
+export default function SettingsScreen() {
+ const { colors, isDarkMode, toggleTheme, themeVariant, setThemeVariant } = useTheme();
+ const loadAll = useLibraryStore((s) => s.loadAll);
+ const [storageInfo, setStorageInfo] = useState({ musicSize: 0, artworkSize: 0, totalFiles: 0 });
+ const [songCount, setSongCount] = useState(0);
+
+ useEffect(() => {
+ loadInfo();
+ }, []);
+
+ const loadInfo = async () => {
+ const [storage, count] = await Promise.all([getStorageInfo(), getSongCount()]);
+ setStorageInfo(storage);
+ setSongCount(count);
+ };
+
+ const handleImport = async () => {
+ try {
+ const songs = await pickAndImportFiles();
+ if (songs.length > 0) {
+ await loadAll();
+ await loadInfo();
+ Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
+ }
+ } catch (error) {
+ Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
+ }
+ };
+
+ const variants: { key: ThemeVariant; label: string; color: string }[] = [
+ { key: 'classic', label: 'Orange', color: '#FF6B35' },
+ { key: 'ocean', label: 'Blau', color: '#2196F3' },
+ { key: 'sunset', label: 'Rot', color: '#FF6B6B' },
+ ];
+
+ return (
+
+
+
+ {/* Appearance */}
+
+ Darstellung
+
+
+
+ Dark Mode
+
+
+
+
+ Akzentfarbe
+
+ {variants.map((v) => (
+ setThemeVariant(v.key)}
+ style={{
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: v.color,
+ borderWidth: themeVariant === v.key ? 3 : 0,
+ borderColor: colors.text,
+ }}
+ />
+ ))}
+
+
+
+
+ {/* Import */}
+
+ Musik
+
+
+
+
+ Songs importieren
+
+
+
+ {/* Storage */}
+
+ Speicher
+
+
+
+
+ Songs
+ {songCount}
+
+
+ Musik
+
+ {formatFileSize(storageInfo.musicSize)}
+
+
+
+ Cover Art
+
+ {formatFileSize(storageInfo.artworkSize)}
+
+
+
+
+
+ {/* About */}
+
+ Info
+
+
+
+
+ Version
+ 1.0.0
+
+
+ Mukke
+ Offline Music Player
+
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/+not-found.tsx b/apps/mukke/apps/mobile/app/+not-found.tsx
new file mode 100644
index 000000000..bb486b5ef
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/+not-found.tsx
@@ -0,0 +1,16 @@
+import { Link, Stack } from 'expo-router';
+import { Text, View } from 'react-native';
+
+export default function NotFoundScreen() {
+ return (
+ <>
+
+
+ Diese Seite existiert nicht.
+
+ Zur Bibliothek
+
+
+ >
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/_layout.tsx b/apps/mukke/apps/mobile/app/_layout.tsx
new file mode 100644
index 000000000..1719ff3c1
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/_layout.tsx
@@ -0,0 +1,78 @@
+import '../global.css';
+import { Stack } from 'expo-router';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+
+import { ThemeWrapper } from '~/components/ThemeWrapper';
+import { AudioProvider } from '~/contexts/AudioContext';
+import { ThemeProvider } from '~/utils/themeContext';
+
+export const unstable_settings = {
+ initialRouteName: '(tabs)',
+};
+
+export default function RootLayout() {
+ return (
+
+
+
+ {({ isDarkMode }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/album/[id].tsx b/apps/mukke/apps/mobile/app/album/[id].tsx
new file mode 100644
index 000000000..23e7ebed7
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/album/[id].tsx
@@ -0,0 +1,53 @@
+import { useLocalSearchParams } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { View, Text } from 'react-native';
+
+import { Artwork } from '~/components/Artwork';
+import { SongList } from '~/components/SongList';
+import { getSongsByAlbum } from '~/services/libraryService';
+import { usePlayerStore } from '~/stores/playerStore';
+import type { Song } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+export default function AlbumDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const albumName = decodeURIComponent(id || '');
+ const { colors } = useTheme();
+ const playSong = usePlayerStore((s) => s.playSong);
+ const [songs, setSongs] = useState([]);
+
+ useEffect(() => {
+ if (albumName) {
+ getSongsByAlbum(albumName).then(setSongs);
+ }
+ }, [albumName]);
+
+ const coverArt = songs.find((s) => s.coverArtPath)?.coverArtPath || null;
+ const artist = songs[0]?.albumArtist || songs[0]?.artist || 'Unbekannt';
+ const year = songs[0]?.year;
+
+ return (
+
+ {/* Album Header */}
+
+
+
+ {albumName}
+
+
+ {artist}
+ {year ? ` Β· ${year}` : ''} Β· {songs.length} Songs
+
+
+
+ playSong(song, songs, index)}
+ emptyTitle="Keine Songs"
+ />
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/artist/[id].tsx b/apps/mukke/apps/mobile/app/artist/[id].tsx
new file mode 100644
index 000000000..5d56e0abc
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/artist/[id].tsx
@@ -0,0 +1,47 @@
+import { useLocalSearchParams } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { View, Text } from 'react-native';
+
+import { Artwork } from '~/components/Artwork';
+import { SongList } from '~/components/SongList';
+import { getSongsByArtist } from '~/services/libraryService';
+import { usePlayerStore } from '~/stores/playerStore';
+import type { Song } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+export default function ArtistDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const artistName = decodeURIComponent(id || '');
+ const { colors } = useTheme();
+ const playSong = usePlayerStore((s) => s.playSong);
+ const [songs, setSongs] = useState([]);
+
+ useEffect(() => {
+ if (artistName) {
+ getSongsByArtist(artistName).then(setSongs);
+ }
+ }, [artistName]);
+
+ const albumCount = new Set(songs.map((s) => s.album).filter(Boolean)).size;
+
+ return (
+
+ {/* Artist Header */}
+
+
+
+ {artistName}
+
+
+ {songs.length} Songs Β· {albumCount} Alben
+
+
+
+ playSong(song, songs, index)}
+ emptyTitle="Keine Songs"
+ />
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/player.tsx b/apps/mukke/apps/mobile/app/player.tsx
new file mode 100644
index 000000000..06cd21c76
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/player.tsx
@@ -0,0 +1,102 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import { View, Text, Pressable } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { Artwork } from '~/components/Artwork';
+import { ProgressBar } from '~/components/ProgressBar';
+import { TransportControls } from '~/components/TransportControls';
+import { useAudio } from '~/contexts/AudioContext';
+import { usePlayerStore } from '~/stores/playerStore';
+import { useLibraryStore } from '~/stores/libraryStore';
+import { useTheme } from '~/utils/themeContext';
+
+export default function PlayerScreen() {
+ const { colors } = useTheme();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { seekTo } = useAudio();
+ const currentSong = usePlayerStore((s) => s.currentSong);
+ const position = usePlayerStore((s) => s.position);
+ const duration = usePlayerStore((s) => s.duration);
+ const toggleFavorite = useLibraryStore((s) => s.toggleFavorite);
+
+ if (!currentSong) {
+ return (
+
+ Kein Song wird abgespielt
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ router.back()} style={{ padding: 4 }}>
+
+
+
+ WIRD ABGESPIELT
+
+ router.push('/queue')} style={{ padding: 4 }}>
+
+
+
+
+ {/* Artwork */}
+
+
+
+
+ {/* Song Info */}
+
+
+
+
+ {currentSong.title}
+
+
+ {currentSong.artist || 'Unbekannt'}
+
+
+ toggleFavorite(currentSong.id)} style={{ padding: 8 }}>
+
+
+
+
+
+ {/* Progress */}
+
+
+ {/* Transport */}
+
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/playlist/[id].tsx b/apps/mukke/apps/mobile/app/playlist/[id].tsx
new file mode 100644
index 000000000..f535999c4
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/playlist/[id].tsx
@@ -0,0 +1,118 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { View, Text, Pressable, Alert } from 'react-native';
+
+import { EmptyState } from '~/components/EmptyState';
+import { SongList } from '~/components/SongList';
+import { SongPicker } from '~/components/SongPicker';
+import {
+ getPlaylistById,
+ getPlaylistSongs,
+ addSongToPlaylist,
+ removeSongFromPlaylist,
+} from '~/services/playlistService';
+import { usePlayerStore } from '~/stores/playerStore';
+import type { Playlist, Song } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+export default function PlaylistDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { colors } = useTheme();
+ const playSong = usePlayerStore((s) => s.playSong);
+ const [playlist, setPlaylist] = useState(null);
+ const [songs, setSongs] = useState([]);
+ const [showPicker, setShowPicker] = useState(false);
+
+ const loadData = async () => {
+ if (!id) return;
+ const [p, s] = await Promise.all([getPlaylistById(id), getPlaylistSongs(id)]);
+ setPlaylist(p);
+ setSongs(s);
+ };
+
+ useEffect(() => {
+ loadData();
+ }, [id]);
+
+ const handleAddSongs = async (selected: Song[]) => {
+ if (!id) return;
+ for (const song of selected) {
+ await addSongToPlaylist(id, song.id);
+ }
+ await loadData();
+ };
+
+ const handleLongPress = (song: Song) => {
+ Alert.alert('Song entfernen', `"${song.title}" aus der Playlist entfernen?`, [
+ { text: 'Abbrechen', style: 'cancel' },
+ {
+ text: 'Entfernen',
+ style: 'destructive',
+ onPress: async () => {
+ if (id) {
+ await removeSongFromPlaylist(id, song.id);
+ await loadData();
+ }
+ },
+ },
+ ]);
+ };
+
+ return (
+
+ (
+ setShowPicker(true)} style={{ padding: 8 }}>
+
+
+ ),
+ }}
+ />
+
+ {playlist && (
+
+
+
+
+
+ {playlist.name}
+
+ {playlist.description && (
+
+ {playlist.description}
+
+ )}
+
+ {songs.length} Songs
+
+
+ )}
+
+ playSong(song, songs, index)}
+ emptyTitle="Playlist ist leer"
+ emptyMessage="FΓΌge Songs ΓΌber den + Button hinzu."
+ />
+
+ setShowPicker(false)}
+ onSelect={handleAddSongs}
+ excludeIds={songs.map((s) => s.id)}
+ />
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/playlist/new.tsx b/apps/mukke/apps/mobile/app/playlist/new.tsx
new file mode 100644
index 000000000..7aa3279cc
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/playlist/new.tsx
@@ -0,0 +1,84 @@
+import { useRouter } from 'expo-router';
+import { useState } from 'react';
+import { View, Text, TextInput, Pressable } from 'react-native';
+
+import { usePlaylistStore } from '~/stores/playlistStore';
+import { useTheme } from '~/utils/themeContext';
+
+export default function NewPlaylistScreen() {
+ const { colors } = useTheme();
+ const router = useRouter();
+ const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+
+ const handleCreate = async () => {
+ if (!name.trim()) return;
+ const playlist = await createPlaylist(name.trim(), description.trim() || undefined);
+ router.dismiss();
+ router.push(`/playlist/${playlist.id}`);
+ };
+
+ return (
+
+ Name
+
+
+
+ Beschreibung (optional)
+
+
+
+
+
+ Erstellen
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/app/queue.tsx b/apps/mukke/apps/mobile/app/queue.tsx
new file mode 100644
index 000000000..6ae972dc9
--- /dev/null
+++ b/apps/mukke/apps/mobile/app/queue.tsx
@@ -0,0 +1,77 @@
+import { Ionicons } from '@expo/vector-icons';
+import { View, Text, FlatList } from 'react-native';
+
+import { Artwork } from '~/components/Artwork';
+import { ListItem } from '~/components/ListItem';
+import { usePlayerStore } from '~/stores/playerStore';
+import { formatDuration } from '~/services/audioService';
+import { useTheme } from '~/utils/themeContext';
+
+export default function QueueScreen() {
+ const { colors } = useTheme();
+ const queue = usePlayerStore((s) => s.getQueue());
+ const currentSong = usePlayerStore((s) => s.currentSong);
+ const playSong = usePlayerStore((s) => s.playSong);
+
+ const currentIndex = queue.findIndex((s) => s.id === currentSong?.id);
+
+ return (
+
+ {currentSong && (
+
+
+ AKTUELLER SONG
+
+
+
+
+
+ {currentSong.title}
+
+
+ {currentSong.artist || 'Unbekannt'}
+
+
+
+
+ )}
+
+
+ ALS NΓCHSTES
+
+
+ `${item.id}-${index}`}
+ contentContainerStyle={{ paddingBottom: 40 }}
+ renderItem={({ item, index }) => (
+ }
+ onPress={() => playSong(item, queue, currentIndex + 1 + index)}
+ />
+ )}
+ />
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/assets/adaptive-icon.png b/apps/mukke/apps/mobile/assets/adaptive-icon.png
new file mode 100644
index 000000000..35c8a1c1f
Binary files /dev/null and b/apps/mukke/apps/mobile/assets/adaptive-icon.png differ
diff --git a/apps/mukke/apps/mobile/assets/favicon.png b/apps/mukke/apps/mobile/assets/favicon.png
new file mode 100644
index 000000000..99d248c29
Binary files /dev/null and b/apps/mukke/apps/mobile/assets/favicon.png differ
diff --git a/apps/mukke/apps/mobile/assets/icon.png b/apps/mukke/apps/mobile/assets/icon.png
new file mode 100644
index 000000000..35c8a1c1f
Binary files /dev/null and b/apps/mukke/apps/mobile/assets/icon.png differ
diff --git a/apps/mukke/apps/mobile/assets/splash.png b/apps/mukke/apps/mobile/assets/splash.png
new file mode 100644
index 000000000..eb2472ec4
Binary files /dev/null and b/apps/mukke/apps/mobile/assets/splash.png differ
diff --git a/apps/mukke/apps/mobile/babel.config.js b/apps/mukke/apps/mobile/babel.config.js
new file mode 100644
index 000000000..d9beb89e8
--- /dev/null
+++ b/apps/mukke/apps/mobile/babel.config.js
@@ -0,0 +1,10 @@
+module.exports = function (api) {
+ api.cache(true);
+ const plugins = [];
+
+ return {
+ presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
+
+ plugins,
+ };
+};
diff --git a/apps/mukke/apps/mobile/components/AlbumGrid.tsx b/apps/mukke/apps/mobile/components/AlbumGrid.tsx
new file mode 100644
index 000000000..16acad16c
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/AlbumGrid.tsx
@@ -0,0 +1,75 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import { FlatList, Image, Pressable, Text, View, useWindowDimensions } from 'react-native';
+
+import type { Album } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+import { EmptyState } from './EmptyState';
+
+interface AlbumGridProps {
+ albums: Album[];
+}
+
+export function AlbumGrid({ albums }: AlbumGridProps) {
+ const router = useRouter();
+ const { colors } = useTheme();
+ const { width } = useWindowDimensions();
+ const itemSize = (width - 48) / 2;
+
+ if (albums.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ item.name}
+ numColumns={2}
+ contentContainerStyle={{ padding: 12, paddingBottom: 100 }}
+ columnWrapperStyle={{ gap: 12 }}
+ ItemSeparatorComponent={() => }
+ renderItem={({ item }) => (
+ router.push(`/album/${encodeURIComponent(item.name)}`)}
+ style={{ width: itemSize }}
+ >
+ {item.coverArtPath ? (
+
+ ) : (
+
+
+
+ )}
+
+ {item.name}
+
+
+ {item.artist || 'Unbekannt'} Β· {item.songCount} Songs
+
+
+ )}
+ />
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/ArtistList.tsx b/apps/mukke/apps/mobile/components/ArtistList.tsx
new file mode 100644
index 000000000..324065014
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/ArtistList.tsx
@@ -0,0 +1,43 @@
+import { useRouter } from 'expo-router';
+import { FlatList } from 'react-native';
+
+import type { Artist } from '~/types';
+
+import { Artwork } from './Artwork';
+import { EmptyState } from './EmptyState';
+import { ListItem } from './ListItem';
+
+interface ArtistListProps {
+ artists: Artist[];
+}
+
+export function ArtistList({ artists }: ArtistListProps) {
+ const router = useRouter();
+
+ if (artists.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ item.name}
+ contentContainerStyle={{ paddingBottom: 100 }}
+ renderItem={({ item }) => (
+ }
+ onPress={() => router.push(`/artist/${encodeURIComponent(item.name)}`)}
+ showChevron
+ />
+ )}
+ />
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/Artwork.tsx b/apps/mukke/apps/mobile/components/Artwork.tsx
new file mode 100644
index 000000000..28db3282a
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/Artwork.tsx
@@ -0,0 +1,42 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Image, View } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+
+interface ArtworkProps {
+ uri: string | null | undefined;
+ size?: number;
+ rounded?: boolean;
+}
+
+export function Artwork({ uri, size = 48, rounded = false }: ArtworkProps) {
+ const { colors } = useTheme();
+
+ if (uri) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/Button.tsx b/apps/mukke/apps/mobile/components/Button.tsx
new file mode 100644
index 000000000..1224076da
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/Button.tsx
@@ -0,0 +1,45 @@
+import { Pressable, Text, ActivityIndicator } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+
+interface ButtonProps {
+ title: string;
+ onPress: () => void;
+ variant?: 'primary' | 'secondary' | 'ghost';
+ loading?: boolean;
+ disabled?: boolean;
+}
+
+export function Button({ title, onPress, variant = 'primary', loading, disabled }: ButtonProps) {
+ const { colors } = useTheme();
+
+ const bgColor =
+ variant === 'primary'
+ ? colors.primary
+ : variant === 'secondary'
+ ? colors.backgroundTertiary
+ : 'transparent';
+
+ const textColor = variant === 'primary' ? '#FFFFFF' : colors.text;
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ {title}
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/EmptyState.tsx b/apps/mukke/apps/mobile/components/EmptyState.tsx
new file mode 100644
index 000000000..ba03907d8
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/EmptyState.tsx
@@ -0,0 +1,43 @@
+import { Ionicons } from '@expo/vector-icons';
+import { View, Text } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+
+interface EmptyStateProps {
+ icon?: keyof typeof Ionicons.glyphMap;
+ title: string;
+ message?: string;
+}
+
+export function EmptyState({ icon = 'musical-notes-outline', title, message }: EmptyStateProps) {
+ const { colors } = useTheme();
+
+ return (
+
+
+
+ {title}
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/GenreList.tsx b/apps/mukke/apps/mobile/components/GenreList.tsx
new file mode 100644
index 000000000..cfb5de52d
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/GenreList.tsx
@@ -0,0 +1,65 @@
+import { Ionicons } from '@expo/vector-icons';
+import { FlatList, View } from 'react-native';
+
+import type { Genre } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+import { usePlayerStore } from '~/stores/playerStore';
+import { getSongsByGenre } from '~/services/libraryService';
+
+import { EmptyState } from './EmptyState';
+import { ListItem } from './ListItem';
+
+interface GenreListProps {
+ genres: Genre[];
+}
+
+export function GenreList({ genres }: GenreListProps) {
+ const { colors } = useTheme();
+ const playSong = usePlayerStore((s) => s.playSong);
+
+ if (genres.length === 0) {
+ return (
+
+ );
+ }
+
+ const handlePress = async (genre: Genre) => {
+ const songs = await getSongsByGenre(genre.name);
+ if (songs.length > 0) {
+ playSong(songs[0], songs, 0);
+ }
+ };
+
+ return (
+ item.name}
+ contentContainerStyle={{ paddingBottom: 100 }}
+ renderItem={({ item }) => (
+
+
+
+ }
+ onPress={() => handlePress(item)}
+ />
+ )}
+ />
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/ImportButton.tsx b/apps/mukke/apps/mobile/components/ImportButton.tsx
new file mode 100644
index 000000000..3202c5b9f
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/ImportButton.tsx
@@ -0,0 +1,36 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useState } from 'react';
+import { Pressable, Alert } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+import { pickAndImportFiles } from '~/services/importService';
+import { useLibraryStore } from '~/stores/libraryStore';
+
+export function ImportButton() {
+ const { colors } = useTheme();
+ const [importing, setImporting] = useState(false);
+ const loadAll = useLibraryStore((s) => s.loadAll);
+
+ const handleImport = async () => {
+ if (importing) return;
+ setImporting(true);
+ try {
+ const songs = await pickAndImportFiles();
+ if (songs.length > 0) {
+ await loadAll();
+ Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
+ }
+ } catch (error) {
+ console.error('Import failed:', error);
+ Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/ListItem.tsx b/apps/mukke/apps/mobile/components/ListItem.tsx
new file mode 100644
index 000000000..d1eabaa91
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/ListItem.tsx
@@ -0,0 +1,66 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Pressable, View, Text } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+
+interface ListItemProps {
+ title: string;
+ subtitle?: string;
+ trailing?: string;
+ left?: React.ReactNode;
+ onPress?: () => void;
+ onLongPress?: () => void;
+ showChevron?: boolean;
+}
+
+export function ListItem({
+ title,
+ subtitle,
+ trailing,
+ left,
+ onPress,
+ onLongPress,
+ showChevron,
+}: ListItemProps) {
+ const { colors } = useTheme();
+
+ return (
+ ({
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ backgroundColor: pressed ? colors.backgroundTertiary : 'transparent',
+ })}
+ >
+ {left && {left}}
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {trailing && (
+ {trailing}
+ )}
+ {showChevron && (
+
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/MiniPlayer.tsx b/apps/mukke/apps/mobile/components/MiniPlayer.tsx
new file mode 100644
index 000000000..e31aa730d
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/MiniPlayer.tsx
@@ -0,0 +1,77 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import { Pressable, View, Text } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+import { useAudio } from '~/contexts/AudioContext';
+import { usePlayerStore } from '~/stores/playerStore';
+
+import { Artwork } from './Artwork';
+
+export function MiniPlayer() {
+ const { colors } = useTheme();
+ const router = useRouter();
+ const { play, pause, playNext } = useAudio();
+ const currentSong = usePlayerStore((s) => s.currentSong);
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+ const position = usePlayerStore((s) => s.position);
+ const duration = usePlayerStore((s) => s.duration);
+
+ if (!currentSong) return null;
+
+ const progress = duration > 0 ? position / duration : 0;
+
+ return (
+ router.push('/player')}
+ style={{
+ position: 'absolute',
+ bottom: 49,
+ left: 0,
+ right: 0,
+ backgroundColor: colors.card,
+ borderTopWidth: 0.5,
+ borderTopColor: colors.border,
+ }}
+ >
+ {/* Progress indicator */}
+
+
+
+
+
+
+
+
+
+ {currentSong.title}
+
+
+ {currentSong.artist || 'Unbekannt'}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/ProgressBar.tsx b/apps/mukke/apps/mobile/components/ProgressBar.tsx
new file mode 100644
index 000000000..ecba85bdc
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/ProgressBar.tsx
@@ -0,0 +1,37 @@
+import Slider from '@react-native-community/slider';
+import { View, Text } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+import { formatDuration } from '~/services/audioService';
+
+interface ProgressBarProps {
+ position: number;
+ duration: number;
+ onSeek: (position: number) => void;
+}
+
+export function ProgressBar({ position, duration, onSeek }: ProgressBarProps) {
+ const { colors } = useTheme();
+
+ return (
+
+ 0 ? position / duration : 0}
+ onSlidingComplete={(value) => onSeek(value * duration)}
+ minimumValue={0}
+ maximumValue={1}
+ minimumTrackTintColor={colors.primary}
+ maximumTrackTintColor={colors.backgroundTertiary}
+ thumbTintColor={colors.primary}
+ />
+
+
+ {formatDuration(position)}
+
+
+ -{formatDuration(Math.max(0, duration - position))}
+
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/SegmentedControl.tsx b/apps/mukke/apps/mobile/components/SegmentedControl.tsx
new file mode 100644
index 000000000..50eadf3db
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/SegmentedControl.tsx
@@ -0,0 +1,57 @@
+import { Pressable, View, Text } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+
+interface SegmentedControlProps {
+ segments: { key: T; label: string }[];
+ selected: T;
+ onSelect: (key: T) => void;
+}
+
+export function SegmentedControl({
+ segments,
+ selected,
+ onSelect,
+}: SegmentedControlProps) {
+ const { colors } = useTheme();
+
+ return (
+
+ {segments.map((seg) => {
+ const isActive = seg.key === selected;
+ return (
+ onSelect(seg.key)}
+ style={{
+ flex: 1,
+ paddingVertical: 8,
+ borderRadius: 6,
+ backgroundColor: isActive ? colors.card : 'transparent',
+ alignItems: 'center',
+ }}
+ >
+
+ {seg.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/SongList.tsx b/apps/mukke/apps/mobile/components/SongList.tsx
new file mode 100644
index 000000000..a459853cd
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/SongList.tsx
@@ -0,0 +1,54 @@
+import { FlatList } from 'react-native';
+
+import type { Song } from '~/types';
+import { formatDuration } from '~/services/audioService';
+import { usePlayerStore } from '~/stores/playerStore';
+
+import { Artwork } from './Artwork';
+import { EmptyState } from './EmptyState';
+import { ListItem } from './ListItem';
+
+interface SongListProps {
+ songs: Song[];
+ onSongPress?: (song: Song, index: number) => void;
+ emptyTitle?: string;
+ emptyMessage?: string;
+}
+
+export function SongList({
+ songs,
+ onSongPress,
+ emptyTitle = 'Keine Songs',
+ emptyMessage = 'Importiere Songs ΓΌber den + Button.',
+}: SongListProps) {
+ const playSong = usePlayerStore((s) => s.playSong);
+
+ const handlePress = (song: Song, index: number) => {
+ if (onSongPress) {
+ onSongPress(song, index);
+ } else {
+ playSong(song, songs, index);
+ }
+ };
+
+ if (songs.length === 0) {
+ return ;
+ }
+
+ return (
+ item.id}
+ renderItem={({ item, index }) => (
+ }
+ onPress={() => handlePress(item, index)}
+ />
+ )}
+ contentContainerStyle={{ paddingBottom: 100 }}
+ />
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/SongPicker.tsx b/apps/mukke/apps/mobile/components/SongPicker.tsx
new file mode 100644
index 000000000..88620afb1
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/SongPicker.tsx
@@ -0,0 +1,110 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useState, useEffect } from 'react';
+import { FlatList, Pressable, View, Text, Modal } from 'react-native';
+
+import type { Song } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+import { getAllSongs } from '~/services/libraryService';
+
+import { Artwork } from './Artwork';
+
+interface SongPickerProps {
+ visible: boolean;
+ onClose: () => void;
+ onSelect: (songs: Song[]) => void;
+ excludeIds?: string[];
+}
+
+export function SongPicker({ visible, onClose, onSelect, excludeIds = [] }: SongPickerProps) {
+ const { colors } = useTheme();
+ const [songs, setSongs] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+
+ useEffect(() => {
+ if (visible) {
+ getAllSongs().then((all) => {
+ setSongs(all.filter((s) => !excludeIds.includes(s.id)));
+ });
+ setSelected(new Set());
+ }
+ }, [visible]);
+
+ const toggleSelection = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const handleDone = () => {
+ const selectedSongs = songs.filter((s) => selected.has(s.id));
+ onSelect(selectedSongs);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Abbrechen
+
+
+ Songs auswΓ€hlen
+
+
+
+ Fertig ({selected.size})
+
+
+
+
+ item.id}
+ renderItem={({ item }) => {
+ const isSelected = selected.has(item.id);
+ return (
+ toggleSelection(item.id)}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ }}
+ >
+
+
+
+
+ {item.title}
+
+
+ {item.artist || 'Unbekannt'}
+
+
+
+ );
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/SortMenu.tsx b/apps/mukke/apps/mobile/components/SortMenu.tsx
new file mode 100644
index 000000000..a86f97a30
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/SortMenu.tsx
@@ -0,0 +1,95 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useState } from 'react';
+import { Pressable, View, Text, Modal } from 'react-native';
+
+import type { SortField, SortDirection } from '~/types';
+import { useTheme } from '~/utils/themeContext';
+
+const SORT_OPTIONS: { field: SortField; label: string }[] = [
+ { field: 'title', label: 'Titel' },
+ { field: 'artist', label: 'KΓΌnstler' },
+ { field: 'album', label: 'Album' },
+ { field: 'addedAt', label: 'HinzugefΓΌgt' },
+ { field: 'playCount', label: 'Wiedergaben' },
+];
+
+interface SortMenuProps {
+ currentField: SortField;
+ currentDirection: SortDirection;
+ onSort: (field: SortField, direction: SortDirection) => void;
+}
+
+export function SortMenu({ currentField, currentDirection, onSort }: SortMenuProps) {
+ const { colors } = useTheme();
+ const [visible, setVisible] = useState(false);
+
+ return (
+ <>
+ setVisible(true)} style={{ padding: 8 }}>
+
+
+
+
+ setVisible(false)}
+ style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }}
+ >
+
+
+ Sortieren
+
+ {SORT_OPTIONS.map((opt) => {
+ const isActive = opt.field === currentField;
+ return (
+ {
+ if (isActive) {
+ onSort(opt.field, currentDirection === 'asc' ? 'desc' : 'asc');
+ } else {
+ onSort(opt.field, 'asc');
+ }
+ setVisible(false);
+ }}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 14,
+ borderBottomWidth: 0.5,
+ borderBottomColor: colors.border,
+ }}
+ >
+
+ {opt.label}
+
+ {isActive && (
+
+ )}
+
+ );
+ })}
+
+
+
+ >
+ );
+}
diff --git a/apps/mukke/apps/mobile/components/ThemeWrapper.tsx b/apps/mukke/apps/mobile/components/ThemeWrapper.tsx
new file mode 100644
index 000000000..7c603ad10
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/ThemeWrapper.tsx
@@ -0,0 +1,20 @@
+import { View } from 'react-native';
+
+import { useTheme } from '../utils/themeContext';
+
+type ThemeWrapperProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+export const ThemeWrapper: React.FC = ({ children, className = '' }) => {
+ const { isDarkMode } = useTheme();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/mukke/apps/mobile/components/TransportControls.tsx b/apps/mukke/apps/mobile/components/TransportControls.tsx
new file mode 100644
index 000000000..c74db9864
--- /dev/null
+++ b/apps/mukke/apps/mobile/components/TransportControls.tsx
@@ -0,0 +1,104 @@
+import { Ionicons } from '@expo/vector-icons';
+import { View, Pressable } from 'react-native';
+
+import { useTheme } from '~/utils/themeContext';
+import { useAudio } from '~/contexts/AudioContext';
+import { usePlayerStore } from '~/stores/playerStore';
+import type { RepeatMode, ShuffleMode } from '~/types';
+
+interface TransportControlsProps {
+ size?: 'small' | 'large';
+}
+
+function getRepeatIcon(mode: RepeatMode): keyof typeof Ionicons.glyphMap {
+ if (mode === 'one') return 'repeat';
+ return 'repeat';
+}
+
+export function TransportControls({ size = 'large' }: TransportControlsProps) {
+ const { colors } = useTheme();
+ const { play, pause, playNext, playPrevious } = useAudio();
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+ const repeatMode = usePlayerStore((s) => s.repeatMode);
+ const shuffleMode = usePlayerStore((s) => s.shuffleMode);
+ const toggleRepeat = usePlayerStore((s) => s.toggleRepeat);
+ const toggleShuffle = usePlayerStore((s) => s.toggleShuffle);
+
+ const iconSize = size === 'large' ? 36 : 24;
+ const playSize = size === 'large' ? 56 : 32;
+
+ return (
+
+ {size === 'large' && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {size === 'large' && (
+
+
+ {repeatMode === 'one' && (
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/mukke/apps/mobile/contexts/AudioContext.tsx b/apps/mukke/apps/mobile/contexts/AudioContext.tsx
new file mode 100644
index 000000000..542811dc1
--- /dev/null
+++ b/apps/mukke/apps/mobile/contexts/AudioContext.tsx
@@ -0,0 +1,140 @@
+import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio';
+import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
+
+import { usePlayerStore } from '~/stores/playerStore';
+import { updatePlayStats, updateSongDuration } from '~/services/libraryService';
+
+interface AudioContextType {
+ play: () => void;
+ pause: () => void;
+ seekTo: (position: number) => void;
+ playNext: () => void;
+ playPrevious: () => void;
+}
+
+const AudioCtx = createContext({
+ play: () => {},
+ pause: () => {},
+ seekTo: () => {},
+ playNext: () => {},
+ playPrevious: () => {},
+});
+
+export const useAudio = () => useContext(AudioCtx);
+
+export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const player = useAudioPlayer(null);
+ const status = useAudioPlayerStatus(player);
+ const { currentSong, isPlaying, setPlaying, setPosition, setDuration, nextSong, previousSong } =
+ usePlayerStore();
+ const hasCountedPlay = useRef(false);
+ const lastSongId = useRef(null);
+
+ // Configure audio mode for background playback
+ useEffect(() => {
+ setAudioModeAsync({
+ playsInSilentMode: true,
+ shouldPlayInBackground: true,
+ });
+ }, []);
+
+ // Load song when currentSong changes
+ useEffect(() => {
+ if (!currentSong) return;
+ if (lastSongId.current === currentSong.id) return;
+ lastSongId.current = currentSong.id;
+ hasCountedPlay.current = false;
+
+ player.replace({ uri: currentSong.filePath });
+
+ // Set lock screen metadata
+ player.setActiveForLockScreen(true, {
+ title: currentSong.title,
+ artist: currentSong.artist || undefined,
+ albumTitle: currentSong.album || undefined,
+ artworkSource: currentSong.coverArtPath ? { uri: currentSong.coverArtPath } : undefined,
+ });
+
+ player.play();
+ }, [currentSong?.id]);
+
+ // Sync play/pause state
+ useEffect(() => {
+ if (!currentSong) return;
+ if (isPlaying && !status.playing) {
+ player.play();
+ } else if (!isPlaying && status.playing) {
+ player.pause();
+ }
+ }, [isPlaying]);
+
+ // Update position and duration from player status
+ useEffect(() => {
+ if (status.currentTime !== undefined) {
+ setPosition(status.currentTime);
+ }
+ if (status.duration && status.duration > 0) {
+ setDuration(status.duration);
+ // Save duration to DB if not yet stored
+ if (currentSong && !currentSong.duration) {
+ updateSongDuration(currentSong.id, status.duration);
+ }
+ }
+ }, [status.currentTime, status.duration]);
+
+ // Count play after 10 seconds
+ useEffect(() => {
+ if (currentSong && status.currentTime > 10 && !hasCountedPlay.current) {
+ hasCountedPlay.current = true;
+ updatePlayStats(currentSong.id);
+ }
+ }, [status.currentTime]);
+
+ // Auto-advance when track ends
+ useEffect(() => {
+ if (status.didJustFinish) {
+ const next = nextSong();
+ if (!next) {
+ setPlaying(false);
+ }
+ }
+ }, [status.didJustFinish]);
+
+ const play = useCallback(() => {
+ player.play();
+ setPlaying(true);
+ }, [player]);
+
+ const pause = useCallback(() => {
+ player.pause();
+ setPlaying(false);
+ }, [player]);
+
+ const seekTo = useCallback(
+ (position: number) => {
+ player.seekTo(position);
+ setPosition(position);
+ },
+ [player]
+ );
+
+ const playNext = useCallback(() => {
+ const song = nextSong();
+ if (!song) setPlaying(false);
+ }, []);
+
+ const playPrevious = useCallback(() => {
+ const song = previousSong();
+ if (song && song.id === currentSong?.id) {
+ // Restart current song
+ player.seekTo(0);
+ setPosition(0);
+ }
+ }, [currentSong?.id, player]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/mukke/apps/mobile/eas.json b/apps/mukke/apps/mobile/eas.json
new file mode 100644
index 000000000..4e29cc0d1
--- /dev/null
+++ b/apps/mukke/apps/mobile/eas.json
@@ -0,0 +1,38 @@
+{
+ "cli": {
+ "version": ">= 16.17.4",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "base": {
+ "node": "22.15.0",
+ "pnpm": "10.18.1",
+ "env": {
+ "PNPM_WORKSPACE_ROOT": "../..",
+ "EAS_BUILD": "true"
+ },
+ "cache": {
+ "disabled": false,
+ "key": "v1",
+ "cacheDefaultPaths": true,
+ "customPaths": ["node_modules", "../../node_modules"]
+ }
+ },
+ "development": {
+ "extends": "base",
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "extends": "base",
+ "distribution": "internal"
+ },
+ "production": {
+ "extends": "base",
+ "autoIncrement": true
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/mukke/apps/mobile/global.css b/apps/mukke/apps/mobile/global.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/apps/mukke/apps/mobile/global.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/mukke/apps/mobile/metro.config.js b/apps/mukke/apps/mobile/metro.config.js
new file mode 100644
index 000000000..6bc2c9dd0
--- /dev/null
+++ b/apps/mukke/apps/mobile/metro.config.js
@@ -0,0 +1,25 @@
+// Learn more https://docs.expo.io/guides/customizing-metro
+const { getDefaultConfig } = require('expo/metro-config');
+const { withNativeWind } = require('nativewind/metro');
+const path = require('path');
+
+// Get the project and workspace root directories
+const projectRoot = __dirname;
+const monorepoRoot = path.resolve(projectRoot, '../../../..');
+
+/** @type {import('expo/metro-config').MetroConfig} */
+const config = getDefaultConfig(projectRoot);
+
+// Watch all files within the monorepo (needed for workspace packages like @mukke/types)
+config.watchFolders = [path.resolve(projectRoot, '../../packages'), monorepoRoot + '/node_modules'];
+
+// Let Metro know where to resolve packages and in what order
+config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, 'node_modules'),
+ path.resolve(monorepoRoot, 'node_modules'),
+];
+
+// Support .cjs and .mjs extensions
+config.resolver.sourceExts = [...config.resolver.sourceExts, 'cjs', 'mjs'];
+
+module.exports = withNativeWind(config, { input: './global.css' });
diff --git a/apps/mukke/apps/mobile/nativewind-env.d.ts b/apps/mukke/apps/mobile/nativewind-env.d.ts
new file mode 100644
index 000000000..958346287
--- /dev/null
+++ b/apps/mukke/apps/mobile/nativewind-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
diff --git a/apps/mukke/apps/mobile/package.json b/apps/mukke/apps/mobile/package.json
new file mode 100644
index 000000000..c520df9a8
--- /dev/null
+++ b/apps/mukke/apps/mobile/package.json
@@ -0,0 +1,66 @@
+{
+ "name": "@mukke/mobile",
+ "version": "1.0.0",
+ "main": "expo-router/entry",
+ "scripts": {
+ "dev": "expo start --dev-client",
+ "start": "expo start --dev-client",
+ "ios": "expo run:ios",
+ "android": "expo run:android",
+ "build:dev": "eas build --profile development",
+ "build:preview": "eas build --profile preview",
+ "build:prod": "eas build --profile production",
+ "prebuild": "expo prebuild",
+ "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
+ "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write"
+ },
+ "dependencies": {
+ "@expo/vector-icons": "^15.0.2",
+ "@missingcore/audio-metadata": "^1.3.0",
+ "@mukke/types": "workspace:*",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-native-community/slider": "5.1.2",
+ "@react-navigation/native": "^7.0.3",
+ "expo": "~55.0.0",
+ "expo-audio": "~55.0.0",
+ "expo-constants": "~55.0.0",
+ "expo-dev-client": "~55.0.0",
+ "expo-dev-launcher": "~55.0.0",
+ "expo-document-picker": "~55.0.0",
+ "expo-file-system": "~55.0.0",
+ "expo-router": "~55.0.0",
+ "expo-sqlite": "~55.0.0",
+ "expo-status-bar": "~55.0.0",
+ "expo-system-ui": "~55.0.0",
+ "nativewind": "^4.2.0",
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "react-native": "0.83.2",
+ "react-native-gesture-handler": "~2.30.0",
+ "react-native-reanimated": "~4.2.1",
+ "react-native-safe-area-context": "~5.6.2",
+ "react-native-screens": "~4.23.0",
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "~0.7.2",
+ "uuid": "^11.1.0",
+ "zustand": "^5.0.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.26.0",
+ "@types/react": "~19.2.14",
+ "@types/uuid": "^10.0.0",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "eslint": "^9.18.0",
+ "eslint-config-universe": "^14.0.0",
+ "prettier": "^3.5.0",
+ "prettier-plugin-tailwindcss": "^0.6.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "~5.9.2"
+ },
+ "eslintConfig": {
+ "extends": "universe/native",
+ "root": true
+ },
+ "private": true
+}
diff --git a/apps/mukke/apps/mobile/services/audioService.ts b/apps/mukke/apps/mobile/services/audioService.ts
new file mode 100644
index 000000000..a23cb05ca
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/audioService.ts
@@ -0,0 +1,17 @@
+import { setAudioModeAsync } from 'expo-audio';
+
+export { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
+
+export async function configureAudioMode(): Promise {
+ await setAudioModeAsync({
+ playsInSilentMode: true,
+ shouldPlayInBackground: true,
+ });
+}
+
+export function formatDuration(seconds: number | null | undefined): string {
+ if (!seconds || seconds <= 0) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
diff --git a/apps/mukke/apps/mobile/services/database.ts b/apps/mukke/apps/mobile/services/database.ts
new file mode 100644
index 000000000..1b763ec92
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/database.ts
@@ -0,0 +1,67 @@
+import * as SQLite from 'expo-sqlite';
+
+let db: SQLite.SQLiteDatabase | null = null;
+
+export async function getDatabase(): Promise {
+ if (db) return db;
+ db = await SQLite.openDatabaseAsync('mukke.db');
+ await initializeDatabase(db);
+ return db;
+}
+
+async function initializeDatabase(database: SQLite.SQLiteDatabase): Promise {
+ await database.execAsync(`
+ PRAGMA journal_mode = WAL;
+ PRAGMA foreign_keys = ON;
+
+ CREATE TABLE IF NOT EXISTS songs (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ artist TEXT,
+ album TEXT,
+ albumArtist TEXT,
+ genre TEXT,
+ trackNumber INTEGER,
+ discNumber INTEGER,
+ year INTEGER,
+ duration REAL,
+ filePath TEXT NOT NULL,
+ fileSize INTEGER,
+ coverArtPath TEXT,
+ addedAt TEXT NOT NULL,
+ lastPlayedAt TEXT,
+ playCount INTEGER DEFAULT 0,
+ favorite INTEGER DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS playlists (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ coverArtPath TEXT,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS playlist_songs (
+ id TEXT PRIMARY KEY,
+ playlistId TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+ songId TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
+ sortOrder INTEGER NOT NULL,
+ addedAt TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
+ CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album);
+ CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
+ CREATE INDEX IF NOT EXISTS idx_songs_favorite ON songs(favorite);
+ CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlistId);
+ `);
+}
+
+export async function closeDatabase(): Promise {
+ if (db) {
+ await db.closeAsync();
+ db = null;
+ }
+}
diff --git a/apps/mukke/apps/mobile/services/fileService.ts b/apps/mukke/apps/mobile/services/fileService.ts
new file mode 100644
index 000000000..715c36d3b
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/fileService.ts
@@ -0,0 +1,102 @@
+import * as FileSystem from 'expo-file-system';
+import { v4 as uuidv4 } from 'uuid';
+
+const MUSIC_DIR = `${FileSystem.documentDirectory}music/`;
+const ARTWORK_DIR = `${FileSystem.documentDirectory}artwork/`;
+
+export async function ensureDirectories(): Promise {
+ const musicInfo = await FileSystem.getInfoAsync(MUSIC_DIR);
+ if (!musicInfo.exists) {
+ await FileSystem.makeDirectoryAsync(MUSIC_DIR, { intermediates: true });
+ }
+
+ const artworkInfo = await FileSystem.getInfoAsync(ARTWORK_DIR);
+ if (!artworkInfo.exists) {
+ await FileSystem.makeDirectoryAsync(ARTWORK_DIR, { intermediates: true });
+ }
+}
+
+export function getFileExtension(uri: string): string {
+ const parts = uri.split('.');
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'mp3';
+}
+
+export async function copyToMusicDirectory(
+ sourceUri: string
+): Promise<{ path: string; id: string }> {
+ await ensureDirectories();
+ const id = uuidv4();
+ const ext = getFileExtension(sourceUri);
+ const destPath = `${MUSIC_DIR}${id}.${ext}`;
+ await FileSystem.copyAsync({ from: sourceUri, to: destPath });
+ return { path: destPath, id };
+}
+
+export async function saveArtwork(data: Uint8Array, songId: string): Promise {
+ await ensureDirectories();
+ const artworkPath = `${ARTWORK_DIR}${songId}.jpg`;
+ const base64 = uint8ArrayToBase64(data);
+ await FileSystem.writeAsStringAsync(artworkPath, base64, {
+ encoding: FileSystem.EncodingType.Base64,
+ });
+ return artworkPath;
+}
+
+function uint8ArrayToBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+export async function deleteFile(path: string): Promise {
+ const info = await FileSystem.getInfoAsync(path);
+ if (info.exists) {
+ await FileSystem.deleteAsync(path);
+ }
+}
+
+export async function getStorageInfo(): Promise<{
+ musicSize: number;
+ artworkSize: number;
+ totalFiles: number;
+}> {
+ let musicSize = 0;
+ let artworkSize = 0;
+ let totalFiles = 0;
+
+ try {
+ const musicFiles = await FileSystem.readDirectoryAsync(MUSIC_DIR);
+ for (const file of musicFiles) {
+ const info = await FileSystem.getInfoAsync(`${MUSIC_DIR}${file}`);
+ if (info.exists && !info.isDirectory && 'size' in info) {
+ musicSize += info.size ?? 0;
+ totalFiles++;
+ }
+ }
+ } catch {
+ // Directory might not exist yet
+ }
+
+ try {
+ const artworkFiles = await FileSystem.readDirectoryAsync(ARTWORK_DIR);
+ for (const file of artworkFiles) {
+ const info = await FileSystem.getInfoAsync(`${ARTWORK_DIR}${file}`);
+ if (info.exists && !info.isDirectory && 'size' in info) {
+ artworkSize += info.size ?? 0;
+ }
+ }
+ } catch {
+ // Directory might not exist yet
+ }
+
+ return { musicSize, artworkSize, totalFiles };
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
diff --git a/apps/mukke/apps/mobile/services/importService.ts b/apps/mukke/apps/mobile/services/importService.ts
new file mode 100644
index 000000000..ebc3f3d42
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/importService.ts
@@ -0,0 +1,104 @@
+import { getAudioMetadata } from '@missingcore/audio-metadata';
+import * as DocumentPicker from 'expo-document-picker';
+import * as FileSystem from 'expo-file-system';
+
+import type { Song } from '~/types';
+
+import { copyToMusicDirectory, saveArtwork } from './fileService';
+import { insertSong } from './libraryService';
+
+const SUPPORTED_TYPES = ['audio/*'];
+
+export async function pickAndImportFiles(): Promise {
+ const result = await DocumentPicker.getDocumentAsync({
+ type: SUPPORTED_TYPES,
+ multiple: true,
+ copyToCacheDirectory: true,
+ });
+
+ if (result.canceled || !result.assets?.length) {
+ return [];
+ }
+
+ const importedSongs: Song[] = [];
+
+ for (const asset of result.assets) {
+ try {
+ const song = await importSingleFile(asset);
+ if (song) {
+ importedSongs.push(song);
+ }
+ } catch (error) {
+ console.warn(`Failed to import ${asset.name}:`, error);
+ }
+ }
+
+ return importedSongs;
+}
+
+async function importSingleFile(asset: DocumentPicker.DocumentPickerAsset): Promise {
+ // Copy to permanent storage
+ const { path: filePath, id } = await copyToMusicDirectory(asset.uri);
+
+ // Get file size
+ const fileInfo = await FileSystem.getInfoAsync(filePath);
+ const fileSize = fileInfo.exists && 'size' in fileInfo ? (fileInfo.size ?? null) : null;
+
+ // Extract metadata
+ let metadata: Awaited> | null = null;
+ try {
+ metadata = await getAudioMetadata(filePath, [
+ 'title',
+ 'artist',
+ 'album',
+ 'albumArtist',
+ 'genre',
+ 'trackNumber',
+ 'year',
+ 'picture',
+ ]);
+ } catch (error) {
+ console.warn('Failed to read metadata:', error);
+ }
+
+ // Save cover art if available
+ let coverArtPath: string | null = null;
+ if (metadata?.metadata?.picture) {
+ try {
+ const pictureData = metadata.metadata.picture;
+ if (pictureData && typeof pictureData === 'object' && 'data' in pictureData) {
+ coverArtPath = await saveArtwork(pictureData.data as Uint8Array, id);
+ }
+ } catch (error) {
+ console.warn('Failed to save cover art:', error);
+ }
+ }
+
+ // Build title from metadata or filename
+ const title = (metadata?.metadata?.title as string) || asset.name.replace(/\.[^.]+$/, '');
+
+ const song: Song = {
+ id,
+ title,
+ artist: (metadata?.metadata?.artist as string) || null,
+ album: (metadata?.metadata?.album as string) || null,
+ albumArtist: (metadata?.metadata?.albumArtist as string) || null,
+ genre: (metadata?.metadata?.genre as string) || null,
+ trackNumber: metadata?.metadata?.trackNumber
+ ? parseInt(String(metadata.metadata.trackNumber), 10) || null
+ : null,
+ discNumber: null,
+ year: metadata?.metadata?.year ? parseInt(String(metadata.metadata.year), 10) || null : null,
+ duration: null,
+ filePath,
+ fileSize,
+ coverArtPath,
+ addedAt: new Date().toISOString(),
+ lastPlayedAt: null,
+ playCount: 0,
+ favorite: false,
+ };
+
+ await insertSong(song);
+ return song;
+}
diff --git a/apps/mukke/apps/mobile/services/libraryService.ts b/apps/mukke/apps/mobile/services/libraryService.ts
new file mode 100644
index 000000000..0c6ac7292
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/libraryService.ts
@@ -0,0 +1,176 @@
+import type { Album, Artist, Genre, Song } from '~/types';
+
+import { getDatabase } from './database';
+import { deleteFile } from './fileService';
+
+export async function insertSong(song: Song): Promise {
+ const db = await getDatabase();
+ await db.runAsync(
+ `INSERT INTO songs (id, title, artist, album, albumArtist, genre, trackNumber, discNumber, year, duration, filePath, fileSize, coverArtPath, addedAt, lastPlayedAt, playCount, favorite)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ song.id,
+ song.title,
+ song.artist,
+ song.album,
+ song.albumArtist,
+ song.genre,
+ song.trackNumber,
+ song.discNumber,
+ song.year,
+ song.duration,
+ song.filePath,
+ song.fileSize,
+ song.coverArtPath,
+ song.addedAt,
+ song.lastPlayedAt,
+ song.playCount,
+ song.favorite ? 1 : 0
+ );
+}
+
+export async function getAllSongs(
+ orderBy: string = 'title',
+ direction: 'ASC' | 'DESC' = 'ASC'
+): Promise {
+ const db = await getDatabase();
+ const validColumns = ['title', 'artist', 'album', 'addedAt', 'playCount'];
+ const col = validColumns.includes(orderBy) ? orderBy : 'title';
+ const dir = direction === 'DESC' ? 'DESC' : 'ASC';
+ const rows = await db.getAllAsync(
+ `SELECT * FROM songs ORDER BY ${col} ${dir}`
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function getSongById(id: string): Promise {
+ const db = await getDatabase();
+ const row = await db.getFirstAsync(
+ 'SELECT * FROM songs WHERE id = ?',
+ id
+ );
+ if (!row) return null;
+ return { ...row, favorite: row.favorite === 1 };
+}
+
+export async function deleteSong(id: string): Promise {
+ const db = await getDatabase();
+ const song = await getSongById(id);
+ if (song) {
+ await deleteFile(song.filePath);
+ if (song.coverArtPath) {
+ await deleteFile(song.coverArtPath);
+ }
+ }
+ await db.runAsync('DELETE FROM songs WHERE id = ?', id);
+}
+
+export async function toggleFavorite(id: string): Promise {
+ const db = await getDatabase();
+ const song = await getSongById(id);
+ if (!song) return false;
+ const newFav = !song.favorite;
+ await db.runAsync('UPDATE songs SET favorite = ? WHERE id = ?', newFav ? 1 : 0, id);
+ return newFav;
+}
+
+export async function updatePlayStats(id: string): Promise {
+ const db = await getDatabase();
+ await db.runAsync(
+ 'UPDATE songs SET playCount = playCount + 1, lastPlayedAt = ? WHERE id = ?',
+ new Date().toISOString(),
+ id
+ );
+}
+
+export async function updateSongDuration(id: string, duration: number): Promise {
+ const db = await getDatabase();
+ await db.runAsync('UPDATE songs SET duration = ? WHERE id = ?', duration, id);
+}
+
+export async function getAlbums(): Promise {
+ const db = await getDatabase();
+ return db.getAllAsync(`
+ SELECT
+ album AS name,
+ COALESCE(albumArtist, artist) AS artist,
+ year,
+ (SELECT coverArtPath FROM songs s2 WHERE s2.album = songs.album AND s2.coverArtPath IS NOT NULL LIMIT 1) AS coverArtPath,
+ COUNT(*) AS songCount
+ FROM songs
+ WHERE album IS NOT NULL AND album != ''
+ GROUP BY album
+ ORDER BY album ASC
+ `);
+}
+
+export async function getSongsByAlbum(albumName: string): Promise {
+ const db = await getDatabase();
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM songs WHERE album = ? ORDER BY discNumber ASC, trackNumber ASC, title ASC',
+ albumName
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function getArtists(): Promise {
+ const db = await getDatabase();
+ return db.getAllAsync(`
+ SELECT
+ artist AS name,
+ COUNT(*) AS songCount,
+ COUNT(DISTINCT album) AS albumCount
+ FROM songs
+ WHERE artist IS NOT NULL AND artist != ''
+ GROUP BY artist
+ ORDER BY artist ASC
+ `);
+}
+
+export async function getSongsByArtist(artistName: string): Promise {
+ const db = await getDatabase();
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM songs WHERE artist = ? ORDER BY album ASC, trackNumber ASC, title ASC',
+ artistName
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function getGenres(): Promise {
+ const db = await getDatabase();
+ return db.getAllAsync(`
+ SELECT
+ genre AS name,
+ COUNT(*) AS songCount
+ FROM songs
+ WHERE genre IS NOT NULL AND genre != ''
+ GROUP BY genre
+ ORDER BY genre ASC
+ `);
+}
+
+export async function getSongsByGenre(genreName: string): Promise {
+ const db = await getDatabase();
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM songs WHERE genre = ? ORDER BY artist ASC, album ASC, trackNumber ASC',
+ genreName
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function searchSongs(query: string): Promise {
+ const db = await getDatabase();
+ const q = `%${query}%`;
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM songs WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? ORDER BY title ASC LIMIT 50',
+ q,
+ q,
+ q
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function getSongCount(): Promise {
+ const db = await getDatabase();
+ const row = await db.getFirstAsync<{ count: number }>('SELECT COUNT(*) as count FROM songs');
+ return row?.count ?? 0;
+}
diff --git a/apps/mukke/apps/mobile/services/playlistService.ts b/apps/mukke/apps/mobile/services/playlistService.ts
new file mode 100644
index 000000000..f6ae210eb
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/playlistService.ts
@@ -0,0 +1,123 @@
+import { v4 as uuidv4 } from 'uuid';
+
+import type { Playlist, PlaylistSong, Song } from '~/types';
+
+import { getDatabase } from './database';
+
+export async function createPlaylist(name: string, description?: string): Promise {
+ const db = await getDatabase();
+ const now = new Date().toISOString();
+ const playlist: Playlist = {
+ id: uuidv4(),
+ name,
+ description: description || null,
+ coverArtPath: null,
+ createdAt: now,
+ updatedAt: now,
+ };
+ await db.runAsync(
+ 'INSERT INTO playlists (id, name, description, coverArtPath, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)',
+ playlist.id,
+ playlist.name,
+ playlist.description,
+ playlist.coverArtPath,
+ playlist.createdAt,
+ playlist.updatedAt
+ );
+ return playlist;
+}
+
+export async function getAllPlaylists(): Promise {
+ const db = await getDatabase();
+ return db.getAllAsync('SELECT * FROM playlists ORDER BY updatedAt DESC');
+}
+
+export async function getPlaylistById(id: string): Promise {
+ const db = await getDatabase();
+ return db.getFirstAsync('SELECT * FROM playlists WHERE id = ?', id);
+}
+
+export async function updatePlaylist(
+ id: string,
+ updates: { name?: string; description?: string }
+): Promise {
+ const db = await getDatabase();
+ const sets: string[] = ['updatedAt = ?'];
+ const values: (string | null)[] = [new Date().toISOString()];
+
+ if (updates.name !== undefined) {
+ sets.push('name = ?');
+ values.push(updates.name);
+ }
+ if (updates.description !== undefined) {
+ sets.push('description = ?');
+ values.push(updates.description);
+ }
+
+ values.push(id);
+ await db.runAsync(`UPDATE playlists SET ${sets.join(', ')} WHERE id = ?`, ...values);
+}
+
+export async function deletePlaylist(id: string): Promise {
+ const db = await getDatabase();
+ await db.runAsync('DELETE FROM playlists WHERE id = ?', id);
+}
+
+export async function addSongToPlaylist(playlistId: string, songId: string): Promise {
+ const db = await getDatabase();
+ const maxOrder = await db.getFirstAsync<{ maxOrder: number | null }>(
+ 'SELECT MAX(sortOrder) as maxOrder FROM playlist_songs WHERE playlistId = ?',
+ playlistId
+ );
+ const sortOrder = (maxOrder?.maxOrder ?? -1) + 1;
+
+ await db.runAsync(
+ 'INSERT INTO playlist_songs (id, playlistId, songId, sortOrder, addedAt) VALUES (?, ?, ?, ?, ?)',
+ uuidv4(),
+ playlistId,
+ songId,
+ sortOrder,
+ new Date().toISOString()
+ );
+
+ await db.runAsync(
+ 'UPDATE playlists SET updatedAt = ? WHERE id = ?',
+ new Date().toISOString(),
+ playlistId
+ );
+}
+
+export async function removeSongFromPlaylist(playlistId: string, songId: string): Promise {
+ const db = await getDatabase();
+ await db.runAsync(
+ 'DELETE FROM playlist_songs WHERE playlistId = ? AND songId = ?',
+ playlistId,
+ songId
+ );
+ await db.runAsync(
+ 'UPDATE playlists SET updatedAt = ? WHERE id = ?',
+ new Date().toISOString(),
+ playlistId
+ );
+}
+
+export async function getPlaylistSongs(playlistId: string): Promise {
+ const db = await getDatabase();
+ const rows = await db.getAllAsync(
+ `SELECT s.* FROM songs s
+ INNER JOIN playlist_songs ps ON s.id = ps.songId
+ WHERE ps.playlistId = ?
+ ORDER BY ps.sortOrder ASC`,
+ playlistId
+ );
+ return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
+}
+
+export async function getPlaylistSongCount(playlistId: string): Promise {
+ const db = await getDatabase();
+ const row = await db.getFirstAsync<{ count: number }>(
+ 'SELECT COUNT(*) as count FROM playlist_songs WHERE playlistId = ?',
+ playlistId
+ );
+ return row?.count ?? 0;
+}
diff --git a/apps/mukke/apps/mobile/services/queueService.ts b/apps/mukke/apps/mobile/services/queueService.ts
new file mode 100644
index 000000000..629c24922
--- /dev/null
+++ b/apps/mukke/apps/mobile/services/queueService.ts
@@ -0,0 +1,60 @@
+import type { Song } from '~/types';
+
+export interface QueueState {
+ queue: Song[];
+ originalQueue: Song[];
+ currentIndex: number;
+}
+
+export function createQueue(songs: Song[], startIndex: number = 0): QueueState {
+ return {
+ queue: [...songs],
+ originalQueue: [...songs],
+ currentIndex: startIndex,
+ };
+}
+
+export function shuffleQueue(state: QueueState): QueueState {
+ const currentSong = state.queue[state.currentIndex];
+ const remaining = state.queue.filter((_, i) => i !== state.currentIndex);
+
+ // Fisher-Yates shuffle
+ for (let i = remaining.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [remaining[i], remaining[j]] = [remaining[j], remaining[i]];
+ }
+
+ return {
+ ...state,
+ queue: currentSong ? [currentSong, ...remaining] : remaining,
+ currentIndex: 0,
+ };
+}
+
+export function unshuffleQueue(state: QueueState): QueueState {
+ const currentSong = state.queue[state.currentIndex];
+ const newIndex = currentSong ? state.originalQueue.findIndex((s) => s.id === currentSong.id) : 0;
+
+ return {
+ ...state,
+ queue: [...state.originalQueue],
+ currentIndex: Math.max(0, newIndex),
+ };
+}
+
+export function getNextIndex(state: QueueState, repeatMode: 'off' | 'all' | 'one'): number | null {
+ if (repeatMode === 'one') return state.currentIndex;
+ if (state.currentIndex < state.queue.length - 1) return state.currentIndex + 1;
+ if (repeatMode === 'all') return 0;
+ return null;
+}
+
+export function getPreviousIndex(
+ state: QueueState,
+ repeatMode: 'off' | 'all' | 'one'
+): number | null {
+ if (repeatMode === 'one') return state.currentIndex;
+ if (state.currentIndex > 0) return state.currentIndex - 1;
+ if (repeatMode === 'all') return state.queue.length - 1;
+ return null;
+}
diff --git a/apps/mukke/apps/mobile/stores/libraryStore.ts b/apps/mukke/apps/mobile/stores/libraryStore.ts
new file mode 100644
index 000000000..adc8e1e64
--- /dev/null
+++ b/apps/mukke/apps/mobile/stores/libraryStore.ts
@@ -0,0 +1,95 @@
+import { create } from 'zustand';
+
+import type { Album, Artist, Genre, LibraryTab, Song, SortDirection, SortField } from '~/types';
+import * as libraryService from '~/services/libraryService';
+
+interface LibraryState {
+ songs: Song[];
+ albums: Album[];
+ artists: Artist[];
+ genres: Genre[];
+ activeTab: LibraryTab;
+ sortField: SortField;
+ sortDirection: SortDirection;
+ isLoading: boolean;
+ songCount: number;
+
+ setActiveTab: (tab: LibraryTab) => void;
+ setSortField: (field: SortField) => void;
+ setSortDirection: (dir: SortDirection) => void;
+ loadSongs: () => Promise;
+ loadAlbums: () => Promise;
+ loadArtists: () => Promise;
+ loadGenres: () => Promise;
+ loadAll: () => Promise;
+ toggleFavorite: (id: string) => Promise;
+}
+
+export const useLibraryStore = create((set, get) => ({
+ songs: [],
+ albums: [],
+ artists: [],
+ genres: [],
+ activeTab: 'songs',
+ sortField: 'title',
+ sortDirection: 'asc',
+ isLoading: false,
+ songCount: 0,
+
+ setActiveTab: (tab) => set({ activeTab: tab }),
+
+ setSortField: (field) => {
+ set({ sortField: field });
+ get().loadSongs();
+ },
+
+ setSortDirection: (dir) => {
+ set({ sortDirection: dir });
+ get().loadSongs();
+ },
+
+ loadSongs: async () => {
+ const { sortField, sortDirection } = get();
+ const songs = await libraryService.getAllSongs(
+ sortField,
+ sortDirection.toUpperCase() as 'ASC' | 'DESC'
+ );
+ set({ songs, songCount: songs.length });
+ },
+
+ loadAlbums: async () => {
+ const albums = await libraryService.getAlbums();
+ set({ albums });
+ },
+
+ loadArtists: async () => {
+ const artists = await libraryService.getArtists();
+ set({ artists });
+ },
+
+ loadGenres: async () => {
+ const genres = await libraryService.getGenres();
+ set({ genres });
+ },
+
+ loadAll: async () => {
+ set({ isLoading: true });
+ try {
+ await Promise.all([
+ get().loadSongs(),
+ get().loadAlbums(),
+ get().loadArtists(),
+ get().loadGenres(),
+ ]);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ toggleFavorite: async (id) => {
+ const newFav = await libraryService.toggleFavorite(id);
+ set((state) => ({
+ songs: state.songs.map((s) => (s.id === id ? { ...s, favorite: newFav } : s)),
+ }));
+ },
+}));
diff --git a/apps/mukke/apps/mobile/stores/playerStore.ts b/apps/mukke/apps/mobile/stores/playerStore.ts
new file mode 100644
index 000000000..3d8de9237
--- /dev/null
+++ b/apps/mukke/apps/mobile/stores/playerStore.ts
@@ -0,0 +1,134 @@
+import { create } from 'zustand';
+
+import type { RepeatMode, ShuffleMode, Song } from '~/types';
+import {
+ createQueue,
+ getNextIndex,
+ getPreviousIndex,
+ shuffleQueue,
+ unshuffleQueue,
+ type QueueState,
+} from '~/services/queueService';
+
+interface PlayerState {
+ currentSong: Song | null;
+ isPlaying: boolean;
+ position: number;
+ duration: number;
+ repeatMode: RepeatMode;
+ shuffleMode: ShuffleMode;
+ queueState: QueueState;
+
+ playSong: (song: Song, queue?: Song[], startIndex?: number) => void;
+ setPlaying: (playing: boolean) => void;
+ setPosition: (position: number) => void;
+ setDuration: (duration: number) => void;
+ toggleRepeat: () => void;
+ toggleShuffle: () => void;
+ nextSong: () => Song | null;
+ previousSong: () => Song | null;
+ getQueue: () => Song[];
+ clearQueue: () => void;
+}
+
+export const usePlayerStore = create((set, get) => ({
+ currentSong: null,
+ isPlaying: false,
+ position: 0,
+ duration: 0,
+ repeatMode: 'off',
+ shuffleMode: 'off',
+ queueState: { queue: [], originalQueue: [], currentIndex: 0 },
+
+ playSong: (song, queue, startIndex) => {
+ let queueState: QueueState;
+ if (queue && queue.length > 0) {
+ const idx = startIndex ?? queue.findIndex((s) => s.id === song.id);
+ queueState = createQueue(queue, Math.max(0, idx));
+ if (get().shuffleMode === 'on') {
+ queueState = shuffleQueue(queueState);
+ }
+ } else {
+ queueState = createQueue([song], 0);
+ }
+ set({ currentSong: song, isPlaying: true, position: 0, duration: 0, queueState });
+ },
+
+ setPlaying: (playing) => set({ isPlaying: playing }),
+ setPosition: (position) => set({ position }),
+ setDuration: (duration) => set({ duration }),
+
+ toggleRepeat: () => {
+ const modes: RepeatMode[] = ['off', 'all', 'one'];
+ const current = modes.indexOf(get().repeatMode);
+ set({ repeatMode: modes[(current + 1) % modes.length] });
+ },
+
+ toggleShuffle: () => {
+ const { shuffleMode, queueState } = get();
+ if (shuffleMode === 'off') {
+ set({
+ shuffleMode: 'on',
+ queueState: shuffleQueue(queueState),
+ });
+ } else {
+ set({
+ shuffleMode: 'off',
+ queueState: unshuffleQueue(queueState),
+ });
+ }
+ },
+
+ nextSong: () => {
+ const { queueState, repeatMode } = get();
+ const nextIdx = getNextIndex(queueState, repeatMode);
+ if (nextIdx === null) {
+ set({ isPlaying: false });
+ return null;
+ }
+ const song = queueState.queue[nextIdx];
+ set({
+ currentSong: song,
+ position: 0,
+ duration: 0,
+ isPlaying: true,
+ queueState: { ...queueState, currentIndex: nextIdx },
+ });
+ return song;
+ },
+
+ previousSong: () => {
+ const { queueState, repeatMode, position } = get();
+ // If more than 3 seconds in, restart current song
+ if (position > 3) {
+ set({ position: 0 });
+ return get().currentSong;
+ }
+ const prevIdx = getPreviousIndex(queueState, repeatMode);
+ if (prevIdx === null) {
+ set({ position: 0 });
+ return get().currentSong;
+ }
+ const song = queueState.queue[prevIdx];
+ set({
+ currentSong: song,
+ position: 0,
+ duration: 0,
+ isPlaying: true,
+ queueState: { ...queueState, currentIndex: prevIdx },
+ });
+ return song;
+ },
+
+ getQueue: () => get().queueState.queue,
+
+ clearQueue: () => {
+ set({
+ currentSong: null,
+ isPlaying: false,
+ position: 0,
+ duration: 0,
+ queueState: { queue: [], originalQueue: [], currentIndex: 0 },
+ });
+ },
+}));
diff --git a/apps/mukke/apps/mobile/stores/playlistStore.ts b/apps/mukke/apps/mobile/stores/playlistStore.ts
new file mode 100644
index 000000000..c0020bab6
--- /dev/null
+++ b/apps/mukke/apps/mobile/stores/playlistStore.ts
@@ -0,0 +1,45 @@
+import { create } from 'zustand';
+
+import type { Playlist } from '~/types';
+import * as playlistService from '~/services/playlistService';
+
+interface PlaylistState {
+ playlists: Playlist[];
+ isLoading: boolean;
+
+ loadPlaylists: () => Promise;
+ createPlaylist: (name: string, description?: string) => Promise;
+ deletePlaylist: (id: string) => Promise;
+ updatePlaylist: (id: string, updates: { name?: string; description?: string }) => Promise;
+}
+
+export const usePlaylistStore = create((set, get) => ({
+ playlists: [],
+ isLoading: false,
+
+ loadPlaylists: async () => {
+ set({ isLoading: true });
+ try {
+ const playlists = await playlistService.getAllPlaylists();
+ set({ playlists });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ createPlaylist: async (name, description) => {
+ const playlist = await playlistService.createPlaylist(name, description);
+ set((state) => ({ playlists: [playlist, ...state.playlists] }));
+ return playlist;
+ },
+
+ deletePlaylist: async (id) => {
+ await playlistService.deletePlaylist(id);
+ set((state) => ({ playlists: state.playlists.filter((p) => p.id !== id) }));
+ },
+
+ updatePlaylist: async (id, updates) => {
+ await playlistService.updatePlaylist(id, updates);
+ await get().loadPlaylists();
+ },
+}));
diff --git a/apps/mukke/apps/mobile/tailwind.config.js b/apps/mukke/apps/mobile/tailwind.config.js
new file mode 100644
index 000000000..7c9a9f5e9
--- /dev/null
+++ b/apps/mukke/apps/mobile/tailwind.config.js
@@ -0,0 +1,24 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
+ darkMode: 'class',
+ presets: [require('nativewind/preset')],
+ theme: {
+ extend: {
+ colors: {
+ primary: '#FF6B35',
+ 'primary-dark': '#E55A2B',
+ accent: '#FF8F65',
+ background: {
+ light: '#FFFFFF',
+ dark: '#121212',
+ },
+ text: {
+ light: '#000000',
+ dark: '#FFFFFF',
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/apps/mukke/apps/mobile/tsconfig.json b/apps/mukke/apps/mobile/tsconfig.json
new file mode 100644
index 000000000..de988058c
--- /dev/null
+++ b/apps/mukke/apps/mobile/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
+}
diff --git a/apps/mukke/apps/mobile/types/index.ts b/apps/mukke/apps/mobile/types/index.ts
new file mode 100644
index 000000000..f2efaad14
--- /dev/null
+++ b/apps/mukke/apps/mobile/types/index.ts
@@ -0,0 +1,14 @@
+export type {
+ Song,
+ Album,
+ Artist,
+ Genre,
+ Playlist,
+ PlaylistSong,
+ RepeatMode,
+ ShuffleMode,
+ LibraryTab,
+ SortField,
+ SortDirection,
+ SortOption,
+} from '@mukke/types';
diff --git a/apps/mukke/apps/mobile/utils/themeContext.tsx b/apps/mukke/apps/mobile/utils/themeContext.tsx
new file mode 100644
index 000000000..84cb39a3a
--- /dev/null
+++ b/apps/mukke/apps/mobile/utils/themeContext.tsx
@@ -0,0 +1,190 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { useColorScheme } from 'react-native';
+
+export type ThemeVariant = 'classic' | 'ocean' | 'sunset';
+export type ThemeMode = 'light' | 'dark';
+
+export interface ThemeColors {
+ primary: string;
+ primaryDark: string;
+ accent: string;
+ background: string;
+ backgroundSecondary: string;
+ backgroundTertiary: string;
+ text: string;
+ textSecondary: string;
+ textTertiary: string;
+ border: string;
+ card: string;
+ success: string;
+ warning: string;
+ error: string;
+ shadow: string;
+}
+
+const THEME_VARIANTS = {
+ classic: {
+ primary: '#FF6B35',
+ primaryDark: '#E55A2B',
+ accent: '#FF8F65',
+ },
+ ocean: {
+ primary: '#2196F3',
+ primaryDark: '#1976D2',
+ accent: '#42A5F5',
+ },
+ sunset: {
+ primary: '#FF6B6B',
+ primaryDark: '#FF5252',
+ accent: '#FF8A80',
+ },
+};
+
+const getThemeColors = (variant: ThemeVariant, isDark: boolean): ThemeColors => {
+ const variantColors = THEME_VARIANTS[variant];
+
+ if (isDark) {
+ return {
+ primary: variantColors.primary,
+ primaryDark: variantColors.primaryDark,
+ accent: variantColors.accent,
+ background: '#121212',
+ backgroundSecondary: '#1E1E1E',
+ backgroundTertiary: '#2D2D2D',
+ text: '#FFFFFF',
+ textSecondary: '#AAAAAA',
+ textTertiary: '#888888',
+ border: '#333333',
+ card: '#1E1E1E',
+ success: '#4CAF50',
+ warning: '#FF9800',
+ error: '#FF6B6B',
+ shadow: '#000000',
+ };
+ } else {
+ return {
+ primary: variantColors.primary,
+ primaryDark: variantColors.primaryDark,
+ accent: variantColors.accent,
+ background: '#F5F5F5',
+ backgroundSecondary: '#FFFFFF',
+ backgroundTertiary: '#F0F0F0',
+ text: '#000000',
+ textSecondary: '#666666',
+ textTertiary: '#999999',
+ border: '#E0E0E0',
+ card: '#FFFFFF',
+ success: '#4CAF50',
+ warning: '#FF9800',
+ error: '#F44336',
+ shadow: '#000000',
+ };
+ }
+};
+
+type ThemeContextType = {
+ isDarkMode: boolean;
+ themeVariant: ThemeVariant;
+ colors: ThemeColors;
+ toggleTheme: () => void;
+ setDarkMode: (isDark: boolean) => void;
+ setThemeVariant: (variant: ThemeVariant) => void;
+};
+
+const ThemeContext = createContext({
+ isDarkMode: false,
+ themeVariant: 'classic',
+ colors: getThemeColors('classic', false),
+ toggleTheme: () => {},
+ setDarkMode: () => {},
+ setThemeVariant: () => {},
+});
+
+export const useTheme = () => useContext(ThemeContext);
+
+const THEME_PREFERENCE_KEY = '@mukke_theme_preference';
+const THEME_VARIANT_KEY = '@mukke_theme_variant';
+
+type ThemeProviderProps = {
+ children: React.ReactNode | ((themeProps: ThemeContextType) => React.ReactNode);
+};
+
+export const ThemeProvider: React.FC = ({ children }) => {
+ const systemColorScheme = useColorScheme();
+ const [isDarkMode, setIsDarkMode] = useState(false);
+ const [themeVariant, setThemeVariantState] = useState('classic');
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ const loadThemePreferences = async () => {
+ try {
+ const [savedPreference, savedVariant] = await Promise.all([
+ AsyncStorage.getItem(THEME_PREFERENCE_KEY),
+ AsyncStorage.getItem(THEME_VARIANT_KEY),
+ ]);
+
+ if (savedPreference !== null) {
+ setIsDarkMode(savedPreference === 'dark');
+ } else {
+ setIsDarkMode(systemColorScheme === 'dark');
+ }
+
+ if (savedVariant !== null && ['classic', 'ocean', 'sunset'].includes(savedVariant)) {
+ setThemeVariantState(savedVariant as ThemeVariant);
+ }
+ } catch (error) {
+ console.error('Failed to load theme preferences', error);
+ } finally {
+ setIsLoaded(true);
+ }
+ };
+
+ loadThemePreferences();
+ }, [systemColorScheme]);
+
+ useEffect(() => {
+ if (isLoaded) {
+ AsyncStorage.setItem(THEME_PREFERENCE_KEY, isDarkMode ? 'dark' : 'light').catch((error) =>
+ console.error('Failed to save theme preference', error)
+ );
+ }
+ }, [isDarkMode, isLoaded]);
+
+ useEffect(() => {
+ if (isLoaded) {
+ AsyncStorage.setItem(THEME_VARIANT_KEY, themeVariant).catch((error) =>
+ console.error('Failed to save theme variant', error)
+ );
+ }
+ }, [themeVariant, isLoaded]);
+
+ const toggleTheme = () => {
+ setIsDarkMode((prev) => !prev);
+ };
+
+ const setDarkMode = (isDark: boolean) => {
+ setIsDarkMode(isDark);
+ };
+
+ const setThemeVariant = (variant: ThemeVariant) => {
+ setThemeVariantState(variant);
+ };
+
+ const colors = getThemeColors(themeVariant, isDarkMode);
+
+ const themeContextValue = {
+ isDarkMode,
+ themeVariant,
+ colors,
+ toggleTheme,
+ setDarkMode,
+ setThemeVariant,
+ };
+
+ return (
+
+ {typeof children === 'function' ? children(themeContextValue) : children}
+
+ );
+};
diff --git a/apps/mukke/package.json b/apps/mukke/package.json
new file mode 100644
index 000000000..44052b0ab
--- /dev/null
+++ b/apps/mukke/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "mukke",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Mukke - Offline-first iOS Music Player",
+ "scripts": {
+ "dev": "pnpm run --filter=@mukke/* --parallel dev"
+ }
+}
diff --git a/apps/mukke/packages/mukke-types/package.json b/apps/mukke/packages/mukke-types/package.json
new file mode 100644
index 000000000..36bd61b35
--- /dev/null
+++ b/apps/mukke/packages/mukke-types/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@mukke/types",
+ "version": "1.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "private": true
+}
diff --git a/apps/mukke/packages/mukke-types/src/index.ts b/apps/mukke/packages/mukke-types/src/index.ts
new file mode 100644
index 000000000..ea55fd397
--- /dev/null
+++ b/apps/mukke/packages/mukke-types/src/index.ts
@@ -0,0 +1,69 @@
+export interface Song {
+ id: string;
+ title: string;
+ artist: string | null;
+ album: string | null;
+ albumArtist: string | null;
+ genre: string | null;
+ trackNumber: number | null;
+ discNumber: number | null;
+ year: number | null;
+ duration: number | null;
+ filePath: string;
+ fileSize: number | null;
+ coverArtPath: string | null;
+ addedAt: string;
+ lastPlayedAt: string | null;
+ playCount: number;
+ favorite: boolean;
+}
+
+export interface Album {
+ name: string;
+ artist: string | null;
+ year: number | null;
+ coverArtPath: string | null;
+ songCount: number;
+}
+
+export interface Artist {
+ name: string;
+ songCount: number;
+ albumCount: number;
+}
+
+export interface Genre {
+ name: string;
+ songCount: number;
+}
+
+export interface Playlist {
+ id: string;
+ name: string;
+ description: string | null;
+ coverArtPath: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PlaylistSong {
+ id: string;
+ playlistId: string;
+ songId: string;
+ sortOrder: number;
+ addedAt: string;
+}
+
+export type RepeatMode = 'off' | 'all' | 'one';
+export type ShuffleMode = 'off' | 'on';
+
+export type LibraryTab = 'songs' | 'albums' | 'artists' | 'genres';
+
+export type SortField = 'title' | 'artist' | 'album' | 'addedAt' | 'playCount';
+export type SortDirection = 'asc' | 'desc';
+
+export interface SortOption {
+ field: SortField;
+ direction: SortDirection;
+ label: string;
+}
diff --git a/apps/mukke/packages/mukke-types/tsconfig.json b/apps/mukke/packages/mukke-types/tsconfig.json
new file mode 100644
index 000000000..64a30e04e
--- /dev/null
+++ b/apps/mukke/packages/mukke-types/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}