mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(mukke): add offline-first iOS music player app
Mukke is a local, offline-first music player for iOS. Songs are imported from iCloud/local files via document picker, stored on device, and played with expo-audio including background playback and lock screen controls. Stack: Expo SDK 55, expo-audio, expo-sqlite, expo-document-picker, @missingcore/audio-metadata, Zustand, NativeWind, Expo Router with NativeTabs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3dc6ec13a4
commit
6c91805b2f
80 changed files with 4035 additions and 241 deletions
6
apps/matrix/apps/mobile/.gitignore
vendored
6
apps/matrix/apps/mobile/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -21,7 +21,7 @@ export default function DMsScreen() {
|
|||
|
||||
const dmInvites = useMemo(
|
||||
() => rooms.filter((r) => r.membership === 'invite' && r.isDirect),
|
||||
[rooms],
|
||||
[rooms]
|
||||
);
|
||||
|
||||
const handleRoomPress = (roomId: string) => {
|
||||
|
|
@ -37,9 +37,7 @@ export default function DMsScreen() {
|
|||
<Text className="text-foreground text-2xl font-bold">Direct Messages</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push('/room/new')}
|
||||
className={({ pressed }) =>
|
||||
`w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
|
||||
>
|
||||
<Plus size={18} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
|
|
@ -69,7 +67,7 @@ export default function DMsScreen() {
|
|||
renderItem={({ item }) => (
|
||||
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
|
||||
)}
|
||||
contentContainerClassName="pb-4"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
ListHeaderComponent={
|
||||
dmInvites.length > 0 ? (
|
||||
<View className="px-4 py-2 bg-primary/10 border-b border-border">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function ChatsScreen() {
|
|||
// Pending invites
|
||||
const invites = useMemo(
|
||||
() => rooms.filter((r) => r.membership === 'invite' && !r.isDirect),
|
||||
[rooms],
|
||||
[rooms]
|
||||
);
|
||||
|
||||
const handleRoomPress = (roomId: string) => {
|
||||
|
|
@ -40,17 +40,13 @@ export default function ChatsScreen() {
|
|||
<View className="flex-row gap-2">
|
||||
<Pressable
|
||||
onPress={() => router.push('/search')}
|
||||
className={({ pressed }) =>
|
||||
`w-9 h-9 bg-surface border border-border rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="w-9 h-9 bg-surface border border-border rounded-full items-center justify-center active:opacity-70"
|
||||
>
|
||||
<Compass size={18} color="#7c6bff" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => router.push('/room/new')}
|
||||
className={({ pressed }) =>
|
||||
`w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
|
||||
>
|
||||
<Plus size={18} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
|
|
@ -84,7 +80,7 @@ export default function ChatsScreen() {
|
|||
renderItem={({ item }) => (
|
||||
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
|
||||
)}
|
||||
contentContainerClassName="pb-4"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
ListHeaderComponent={
|
||||
invites.length > 0 ? (
|
||||
<View className="px-4 py-2 bg-primary/10 border-b border-border">
|
||||
|
|
|
|||
|
|
@ -4,14 +4,26 @@ import { Image } from 'expo-image';
|
|||
import { useMatrixStore } from '~/src/matrix/store';
|
||||
import type { SimpleRoom } from '~/src/matrix/types';
|
||||
|
||||
function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: () => void; onDecline: () => void }) {
|
||||
function InviteCard({
|
||||
room,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
room: SimpleRoom;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="mx-4 mb-3 bg-surface border border-border rounded-2xl overflow-hidden">
|
||||
<View className="flex-row items-center gap-3 p-4">
|
||||
{/* Avatar */}
|
||||
<View className="w-12 h-12 rounded-full bg-background border border-border overflow-hidden items-center justify-center">
|
||||
{room.avatar ? (
|
||||
<Image source={{ uri: room.avatar }} style={{ width: 48, height: 48 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: room.avatar }}
|
||||
style={{ width: 48, height: 48 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground text-lg font-semibold">
|
||||
{(room.name ?? '?')[0].toUpperCase()}
|
||||
|
|
@ -30,17 +42,15 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
|
|||
</Text>
|
||||
)}
|
||||
{room.inviter && (
|
||||
<Text className="text-muted-foreground text-xs mt-0.5">
|
||||
Invited by {room.inviter}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-xs mt-0.5">Invited by {room.inviter}</Text>
|
||||
)}
|
||||
<View className="flex-row items-center gap-1 mt-1">
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
|
||||
{room.isDirect
|
||||
? 'Direct message'
|
||||
: `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
{room.isEncrypted && (
|
||||
<Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>
|
||||
)}
|
||||
{room.isEncrypted && <Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -49,15 +59,13 @@ function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept:
|
|||
<View className="flex-row border-t border-border">
|
||||
<Pressable
|
||||
onPress={onDecline}
|
||||
className={({ pressed }) =>
|
||||
`flex-1 py-3 items-center border-r border-border ${pressed ? 'bg-surface' : ''}`
|
||||
}
|
||||
className="flex-1 py-3 items-center border-r border-border active:bg-surface"
|
||||
>
|
||||
<Text className="text-destructive font-medium text-sm">Decline</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
className={({ pressed }) => `flex-1 py-3 items-center ${pressed ? 'bg-primary/80' : 'bg-primary'}`}
|
||||
className="flex-1 py-3 items-center bg-primary active:bg-primary/80"
|
||||
>
|
||||
<Text className="text-white font-semibold text-sm">Accept</Text>
|
||||
</Pressable>
|
||||
|
|
@ -114,7 +122,7 @@ export default function InvitesScreen() {
|
|||
onDecline={() => handleDecline(item.id, item.name)}
|
||||
/>
|
||||
)}
|
||||
contentContainerClassName="pt-2 pb-6"
|
||||
contentContainerStyle={{ paddingTop: 8, paddingBottom: 24 }}
|
||||
ListEmptyComponent={
|
||||
<View className="items-center justify-center py-24">
|
||||
<Text className="text-4xl mb-3">✉️</Text>
|
||||
|
|
|
|||
|
|
@ -41,17 +41,19 @@ export default function SettingsScreen() {
|
|||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
|
||||
// Get current profile from client
|
||||
const profileInfo = client ? (() => {
|
||||
try {
|
||||
const user = client.getUser(userId);
|
||||
return {
|
||||
displayName: user?.displayName ?? userId.split(':')[0].slice(1),
|
||||
avatarUrl: user?.avatarUrl ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined };
|
||||
}
|
||||
})() : { displayName: '', avatarUrl: undefined };
|
||||
const profileInfo = client
|
||||
? (() => {
|
||||
try {
|
||||
const user = client.getUser(userId);
|
||||
return {
|
||||
displayName: user?.displayName ?? userId.split(':')[0].slice(1),
|
||||
avatarUrl: user?.avatarUrl ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined };
|
||||
}
|
||||
})()
|
||||
: { displayName: '', avatarUrl: undefined };
|
||||
|
||||
const handleEditName = () => {
|
||||
setNewDisplayName(profileInfo.displayName);
|
||||
|
|
@ -107,15 +109,11 @@ export default function SettingsScreen() {
|
|||
<Text className="text-foreground text-2xl font-bold">Settings</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1" contentContainerClassName="p-4 gap-4">
|
||||
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 16 }}>
|
||||
{/* Profile card */}
|
||||
<View className="bg-surface rounded-2xl border border-border p-4 items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<Pressable
|
||||
onPress={handleChangeAvatar}
|
||||
disabled={uploadingAvatar}
|
||||
className="relative"
|
||||
>
|
||||
<Pressable onPress={handleChangeAvatar} disabled={uploadingAvatar} className="relative">
|
||||
<ProfileAvatar
|
||||
displayName={profileInfo.displayName}
|
||||
avatarUrl={profileInfo.avatarUrl}
|
||||
|
|
@ -148,18 +146,26 @@ export default function SettingsScreen() {
|
|||
{/* Connection info */}
|
||||
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
|
||||
<View className="px-4 py-3 border-b border-border">
|
||||
<Text className="text-muted-foreground text-xs uppercase tracking-wider">Connection</Text>
|
||||
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Connection
|
||||
</Text>
|
||||
</View>
|
||||
<View className="p-4 gap-3">
|
||||
<View>
|
||||
<Text className="text-muted-foreground text-xs">Homeserver</Text>
|
||||
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>{homeserver || '—'}</Text>
|
||||
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>
|
||||
{homeserver || '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-muted-foreground text-xs">Sync status</Text>
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<View className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`} />
|
||||
<Text className="text-foreground text-sm capitalize">{syncState.toLowerCase()}</Text>
|
||||
<View
|
||||
className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`}
|
||||
/>
|
||||
<Text className="text-foreground text-sm capitalize">
|
||||
{syncState.toLowerCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -189,16 +195,19 @@ export default function SettingsScreen() {
|
|||
{/* Sign out */}
|
||||
<Pressable
|
||||
onPress={handleLogout}
|
||||
className={({ pressed }) =>
|
||||
`bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center active:opacity-60"
|
||||
>
|
||||
<Text className="text-destructive font-semibold">Sign out</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
|
||||
{/* Edit display name modal */}
|
||||
<Modal visible={editingName} transparent animationType="fade" onRequestClose={() => setEditingName(false)}>
|
||||
<Modal
|
||||
visible={editingName}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setEditingName(false)}
|
||||
>
|
||||
<View className="flex-1 bg-black/60 items-center justify-center p-6">
|
||||
<View className="bg-surface border border-border rounded-2xl p-5 w-full gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
|
|
@ -218,9 +227,7 @@ export default function SettingsScreen() {
|
|||
<Pressable
|
||||
onPress={handleSaveName}
|
||||
disabled={savingName || !newDisplayName.trim()}
|
||||
className={({ pressed }) =>
|
||||
`bg-primary rounded-xl py-3 items-center ${pressed || savingName || !newDisplayName.trim() ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="bg-primary rounded-xl py-3 items-center active:opacity-60"
|
||||
>
|
||||
{savingName ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function LoginScreen() {
|
|||
base,
|
||||
response.access_token,
|
||||
response.user_id,
|
||||
response.device_id,
|
||||
response.device_id
|
||||
);
|
||||
if (loginResult.success && loginResult.credentials) {
|
||||
await initialize(loginResult.credentials);
|
||||
|
|
@ -112,7 +112,7 @@ export default function LoginScreen() {
|
|||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="flex-grow justify-center p-6"
|
||||
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: 24 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Logo */}
|
||||
|
|
@ -134,7 +134,10 @@ export default function LoginScreen() {
|
|||
<TextInput
|
||||
className="flex-1 bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
|
||||
value={homeserver}
|
||||
onChangeText={(v) => { setHomeserver(v); setServerOk(null); }}
|
||||
onChangeText={(v) => {
|
||||
setHomeserver(v);
|
||||
setServerOk(null);
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
|
|
@ -187,9 +190,7 @@ export default function LoginScreen() {
|
|||
<Pressable
|
||||
onPress={handleLogin}
|
||||
disabled={loading || ssoLoading}
|
||||
className={({ pressed }) =>
|
||||
`bg-primary rounded-xl py-4 items-center mt-1 ${pressed || loading ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="bg-primary rounded-xl py-4 items-center mt-1 active:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
|
|
@ -209,18 +210,12 @@ export default function LoginScreen() {
|
|||
<Pressable
|
||||
onPress={handleSSO}
|
||||
disabled={loading || ssoLoading}
|
||||
className={({ pressed }) =>
|
||||
`bg-surface border border-border rounded-xl py-4 items-center ${
|
||||
pressed || ssoLoading ? 'opacity-70' : ''
|
||||
}`
|
||||
}
|
||||
className="bg-surface border border-border rounded-xl py-4 items-center active:opacity-70"
|
||||
>
|
||||
{ssoLoading ? (
|
||||
<ActivityIndicator color="#7c6bff" />
|
||||
) : (
|
||||
<Text className="text-foreground font-medium text-base">
|
||||
Sign in with SSO
|
||||
</Text>
|
||||
<Text className="text-foreground font-medium text-base">Sign in with SSO</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -36,10 +36,13 @@ type ListItem =
|
|||
| { type: 'unread'; key: string };
|
||||
|
||||
function isSameDay(a: number, b: number) {
|
||||
const da = new Date(a), db = new Date(b);
|
||||
return da.getFullYear() === db.getFullYear() &&
|
||||
const da = new Date(a),
|
||||
db = new Date(b);
|
||||
return (
|
||||
da.getFullYear() === db.getFullYear() &&
|
||||
da.getMonth() === db.getMonth() &&
|
||||
da.getDate() === db.getDate();
|
||||
da.getDate() === db.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function buildListItems(messages: SimpleMessage[], firstUnreadEventId: string | null): ListItem[] {
|
||||
|
|
@ -65,11 +68,15 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
|
|||
<>
|
||||
<Pressable
|
||||
onPress={() => setShowProfile(true)}
|
||||
className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
|
||||
className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
|
||||
>
|
||||
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
|
||||
{member.avatarUrl ? (
|
||||
<Image source={{ uri: member.avatarUrl }} style={{ width: 40, height: 40 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: member.avatarUrl }}
|
||||
style={{ width: 40, height: 40 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground font-semibold">
|
||||
{member.displayName[0]?.toUpperCase() ?? '?'}
|
||||
|
|
@ -78,7 +85,9 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
|
|||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground text-sm font-medium">{member.displayName}</Text>
|
||||
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{member.userId}</Text>
|
||||
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
|
||||
{member.userId}
|
||||
</Text>
|
||||
</View>
|
||||
{member.powerLevel >= 100 && (
|
||||
<View className="bg-primary/20 rounded-full px-2 py-0.5">
|
||||
|
|
@ -93,7 +102,10 @@ function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => voi
|
|||
</Pressable>
|
||||
<UserProfileModal
|
||||
userId={showProfile ? member.userId : null}
|
||||
onClose={() => { setShowProfile(false); onClose(); }}
|
||||
onClose={() => {
|
||||
setShowProfile(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -116,10 +128,25 @@ export default function RoomScreen() {
|
|||
const [forwardSearch, setForwardSearch] = useState('');
|
||||
|
||||
const {
|
||||
rooms, messages, firstUnreadEventId, typingUsers, roomMembers, client, credentials,
|
||||
selectRoom, loadRoomMembers, sendMessage, editMessage,
|
||||
sendReaction, redactMessage, sendTyping,
|
||||
sendImage, sendFile, sendVoice, forwardMessage, leaveRoom,
|
||||
rooms,
|
||||
messages,
|
||||
firstUnreadEventId,
|
||||
typingUsers,
|
||||
roomMembers,
|
||||
client,
|
||||
credentials,
|
||||
selectRoom,
|
||||
loadRoomMembers,
|
||||
sendMessage,
|
||||
editMessage,
|
||||
sendReaction,
|
||||
redactMessage,
|
||||
sendTyping,
|
||||
sendImage,
|
||||
sendFile,
|
||||
sendVoice,
|
||||
forwardMessage,
|
||||
leaveRoom,
|
||||
} = useMatrixStore();
|
||||
|
||||
const room = rooms.find((r) => r.id === id);
|
||||
|
|
@ -130,9 +157,14 @@ export default function RoomScreen() {
|
|||
return (matrixRoom?.getMember(userId)?.powerLevel ?? 0) >= 100;
|
||||
}, [client, id]);
|
||||
|
||||
useEffect(() => { if (id) selectRoom(id); }, [id]);
|
||||
useEffect(() => {
|
||||
if (id) selectRoom(id);
|
||||
}, [id]);
|
||||
|
||||
const listItems = useMemo(() => buildListItems(messages, firstUnreadEventId), [messages, firstUnreadEventId]);
|
||||
const listItems = useMemo(
|
||||
() => buildListItems(messages, firstUnreadEventId),
|
||||
[messages, firstUnreadEventId]
|
||||
);
|
||||
|
||||
// Scroll to first unread message on initial load
|
||||
useEffect(() => {
|
||||
|
|
@ -150,8 +182,11 @@ export default function RoomScreen() {
|
|||
const matrixRoom = client.getRoom(id);
|
||||
if (!matrixRoom) return;
|
||||
setLoadingMore(true);
|
||||
try { await client.scrollback(matrixRoom, 30); }
|
||||
finally { setLoadingMore(false); }
|
||||
try {
|
||||
await client.scrollback(matrixRoom, 30);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomOptions = () => {
|
||||
|
|
@ -162,15 +197,33 @@ export default function RoomScreen() {
|
|||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{ options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex },
|
||||
(index) => {
|
||||
if (index === 1) { loadRoomMembers(id!); setShowMembers(true); }
|
||||
if (isAdmin && index === 2) { router.push({ pathname: '/room/settings', params: { id } }); }
|
||||
if (index === 1) {
|
||||
loadRoomMembers(id!);
|
||||
setShowMembers(true);
|
||||
}
|
||||
if (isAdmin && index === 2) {
|
||||
router.push({ pathname: '/room/settings', params: { id } });
|
||||
}
|
||||
if (index === options.length - 1) handleLeave();
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
Alert.alert(room?.name ?? 'Room', undefined, [
|
||||
{ text: 'Members', onPress: () => { loadRoomMembers(id!); setShowMembers(true); } },
|
||||
...(isAdmin ? [{ text: 'Room settings', onPress: () => router.push({ pathname: '/room/settings', params: { id } }) }] : []),
|
||||
{
|
||||
text: 'Members',
|
||||
onPress: () => {
|
||||
loadRoomMembers(id!);
|
||||
setShowMembers(true);
|
||||
},
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
text: 'Room settings',
|
||||
onPress: () => router.push({ pathname: '/room/settings', params: { id } }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ text: 'Leave room', style: 'destructive' as const, onPress: handleLeave },
|
||||
{ text: 'Cancel', style: 'cancel' as const },
|
||||
]);
|
||||
|
|
@ -199,7 +252,7 @@ export default function RoomScreen() {
|
|||
if (index === 1) pickImage('library');
|
||||
if (index === 2) pickImage('camera');
|
||||
if (index === 3) pickDocument();
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Attach', undefined, [
|
||||
|
|
@ -212,17 +265,26 @@ export default function RoomScreen() {
|
|||
};
|
||||
|
||||
const pickImage = async (source: 'library' | 'camera') => {
|
||||
const fn = source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
|
||||
const fn =
|
||||
source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
|
||||
const result = await fn({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.85 });
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
const asset = result.assets[0];
|
||||
const filename = asset.fileName ?? `image_${Date.now()}.jpg`;
|
||||
setUploading(true);
|
||||
try {
|
||||
await sendImage(asset.uri, filename, asset.mimeType ?? getMimetypeFromFilename(filename), asset.width, asset.height);
|
||||
await sendImage(
|
||||
asset.uri,
|
||||
filename,
|
||||
asset.mimeType ?? getMimetypeFromFilename(filename),
|
||||
asset.width,
|
||||
asset.height
|
||||
);
|
||||
} catch (err) {
|
||||
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally { setUploading(false); }
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pickDocument = async () => {
|
||||
|
|
@ -234,7 +296,9 @@ export default function RoomScreen() {
|
|||
await sendFile(asset.uri, asset.name, asset.mimeType ?? getMimetypeFromFilename(asset.name));
|
||||
} catch (err) {
|
||||
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally { setUploading(false); }
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForward = useCallback((msg: SimpleMessage) => {
|
||||
|
|
@ -242,28 +306,37 @@ export default function RoomScreen() {
|
|||
setForwardSearch('');
|
||||
}, []);
|
||||
|
||||
const handleForwardToRoom = useCallback(async (targetRoom: SimpleRoom) => {
|
||||
if (!forwardingMessage) return;
|
||||
try {
|
||||
await forwardMessage(forwardingMessage.id, targetRoom.id);
|
||||
setForwardingMessage(null);
|
||||
} catch (err) {
|
||||
Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}, [forwardingMessage, forwardMessage]);
|
||||
const handleForwardToRoom = useCallback(
|
||||
async (targetRoom: SimpleRoom) => {
|
||||
if (!forwardingMessage) return;
|
||||
try {
|
||||
await forwardMessage(forwardingMessage.id, targetRoom.id);
|
||||
setForwardingMessage(null);
|
||||
} catch (err) {
|
||||
Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
},
|
||||
[forwardingMessage, forwardMessage]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((msg: SimpleMessage) => {
|
||||
setReplyTo(null);
|
||||
setEditingMessage(msg);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async (body: string, replyToEventId?: string) => {
|
||||
await sendMessage(body, replyToEventId);
|
||||
}, [sendMessage]);
|
||||
const handleSend = useCallback(
|
||||
async (body: string, replyToEventId?: string) => {
|
||||
await sendMessage(body, replyToEventId);
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
const handleEditSave = useCallback(async (eventId: string, newBody: string) => {
|
||||
await editMessage(eventId, newBody);
|
||||
}, [editMessage]);
|
||||
const handleEditSave = useCallback(
|
||||
async (eventId: string, newBody: string) => {
|
||||
await editMessage(eventId, newBody);
|
||||
},
|
||||
[editMessage]
|
||||
);
|
||||
|
||||
const renderItem = ({ item, index }: { item: ListItem; index: number }) => {
|
||||
if (item.type === 'date') return <DateSeparator timestamp={item.timestamp} />;
|
||||
|
|
@ -273,7 +346,10 @@ export default function RoomScreen() {
|
|||
<MessageBubble
|
||||
message={item.data}
|
||||
prevMessage={messages[msgIndex - 1] ?? null}
|
||||
onReply={(msg) => { setEditingMessage(null); setReplyTo(msg); }}
|
||||
onReply={(msg) => {
|
||||
setEditingMessage(null);
|
||||
setReplyTo(msg);
|
||||
}}
|
||||
onEdit={handleEdit}
|
||||
onReact={sendReaction}
|
||||
onDelete={redactMessage}
|
||||
|
|
@ -288,7 +364,7 @@ export default function RoomScreen() {
|
|||
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
|
||||
<ArrowLeft size={22} color="#7c6bff" />
|
||||
</Pressable>
|
||||
<View className="flex-1">
|
||||
|
|
@ -299,14 +375,16 @@ export default function RoomScreen() {
|
|||
{room?.isEncrypted && <Lock size={12} color="#22c55e" weight="fill" />}
|
||||
</View>
|
||||
{room?.topic ? (
|
||||
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{room.topic}</Text>
|
||||
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
|
||||
{room.topic}
|
||||
</Text>
|
||||
) : room?.memberCount != null ? (
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable onPress={handleRoomOptions} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={handleRoomOptions} className="p-1 active:opacity-50">
|
||||
<DotsThreeVertical size={22} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
|
@ -321,9 +399,9 @@ export default function RoomScreen() {
|
|||
<FlatList
|
||||
ref={listRef}
|
||||
data={listItems}
|
||||
keyExtractor={(item) => item.type === 'message' ? item.data.id : item.key}
|
||||
keyExtractor={(item) => (item.type === 'message' ? item.data.id : item.key)}
|
||||
renderItem={renderItem}
|
||||
contentContainerClassName="px-0 py-2"
|
||||
contentContainerStyle={{ paddingHorizontal: 0, paddingVertical: 8 }}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.15}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
|
|
@ -342,9 +420,14 @@ export default function RoomScreen() {
|
|||
<VoiceRecorder
|
||||
onSend={async (uri, durationMs) => {
|
||||
setUploading(true);
|
||||
try { await sendVoice(uri, durationMs); }
|
||||
catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); }
|
||||
finally { setUploading(false); setShowVoiceRecorder(false); }
|
||||
try {
|
||||
await sendVoice(uri, durationMs);
|
||||
} catch (err) {
|
||||
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setShowVoiceRecorder(false);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setShowVoiceRecorder(false)}
|
||||
/>
|
||||
|
|
@ -363,22 +446,33 @@ export default function RoomScreen() {
|
|||
)}
|
||||
|
||||
{/* Members modal */}
|
||||
<Modal visible={showMembers} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowMembers(false)}>
|
||||
<Modal
|
||||
visible={showMembers}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowMembers(false)}
|
||||
>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Text className="text-foreground text-lg font-semibold">
|
||||
Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
|
||||
</Text>
|
||||
<Pressable onPress={() => setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={() => setShowMembers(false)} className="p-1 active:opacity-50">
|
||||
<X size={22} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerClassName="py-2">
|
||||
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
|
||||
{roomMembers.length === 0 ? (
|
||||
<View className="items-center py-10"><ActivityIndicator color="#7c6bff" /></View>
|
||||
<View className="items-center py-10">
|
||||
<ActivityIndicator color="#7c6bff" />
|
||||
</View>
|
||||
) : (
|
||||
roomMembers.map((member) => (
|
||||
<MemberRow key={member.userId} member={member} onClose={() => setShowMembers(false)} />
|
||||
<MemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
onClose={() => setShowMembers(false)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
|
@ -390,11 +484,16 @@ export default function RoomScreen() {
|
|||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||
|
||||
{/* Forward message modal */}
|
||||
<Modal visible={!!forwardingMessage} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setForwardingMessage(null)}>
|
||||
<Modal
|
||||
visible={!!forwardingMessage}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setForwardingMessage(null)}
|
||||
>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Text className="text-foreground text-lg font-semibold">Forward to...</Text>
|
||||
<Pressable onPress={() => setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={() => setForwardingMessage(null)} className="p-1 active:opacity-50">
|
||||
<X size={22} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
|
@ -411,21 +510,29 @@ export default function RoomScreen() {
|
|||
{forwardingMessage && (
|
||||
<View className="mx-4 mb-2 px-3 py-2 bg-surface border border-border rounded-xl">
|
||||
<Text className="text-muted-foreground text-xs mb-0.5">Message:</Text>
|
||||
<Text className="text-foreground text-sm" numberOfLines={2}>{forwardingMessage.body}</Text>
|
||||
<Text className="text-foreground text-sm" numberOfLines={2}>
|
||||
{forwardingMessage.body}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView contentContainerClassName="py-1">
|
||||
<ScrollView contentContainerStyle={{ paddingVertical: 4 }}>
|
||||
{rooms
|
||||
.filter((r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase()))
|
||||
.filter(
|
||||
(r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase())
|
||||
)
|
||||
.map((r) => (
|
||||
<Pressable
|
||||
key={r.id}
|
||||
onPress={() => handleForwardToRoom(r)}
|
||||
className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
|
||||
className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
|
||||
>
|
||||
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
|
||||
{r.avatar ? (
|
||||
<Image source={{ uri: r.avatar }} style={{ width: 40, height: 40 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: r.avatar }}
|
||||
style={{ width: 40, height: 40 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground font-semibold">
|
||||
{r.name[0]?.toUpperCase() ?? '?'}
|
||||
|
|
@ -433,8 +540,12 @@ export default function RoomScreen() {
|
|||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>{r.name}</Text>
|
||||
{r.isDirect && <Text className="text-muted-foreground text-xs">Direct message</Text>}
|
||||
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>
|
||||
{r.name}
|
||||
</Text>
|
||||
{r.isDirect && (
|
||||
<Text className="text-muted-foreground text-xs">Direct message</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -83,10 +83,7 @@ export default function NewRoomScreen() {
|
|||
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
|
||||
<ArrowLeft size={22} color="#7c6bff" />
|
||||
</Pressable>
|
||||
<Text className="text-foreground text-lg font-semibold">New conversation</Text>
|
||||
|
|
@ -97,7 +94,7 @@ export default function NewRoomScreen() {
|
|||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="p-4 gap-5"
|
||||
contentContainerStyle={{ padding: 16, gap: 20 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Mode toggle */}
|
||||
|
|
@ -195,9 +192,7 @@ export default function NewRoomScreen() {
|
|||
<Pressable
|
||||
onPress={handleCreate}
|
||||
disabled={loading}
|
||||
className={({ pressed }) =>
|
||||
`bg-primary rounded-xl py-4 items-center ${pressed || loading ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="bg-primary rounded-xl py-4 items-center active:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, TextInput, Pressable, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ArrowLeft, Camera } from 'phosphor-react-native';
|
||||
|
|
@ -46,7 +54,9 @@ export default function RoomSettingsScreen() {
|
|||
const uploaded = await uploadMedia(client, asset.uri, filename, 'image/jpeg');
|
||||
setNewAvatarMxc(uploaded.mxcUrl);
|
||||
setAvatarUri(
|
||||
credentials ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) : asset.uri,
|
||||
credentials
|
||||
? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri)
|
||||
: asset.uri
|
||||
);
|
||||
} catch (err) {
|
||||
Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
|
|
@ -80,36 +90,34 @@ export default function RoomSettingsScreen() {
|
|||
};
|
||||
|
||||
const hasChanges =
|
||||
name.trim() !== room?.name ||
|
||||
topic.trim() !== (room?.topic ?? '') ||
|
||||
newAvatarMxc !== null;
|
||||
name.trim() !== room?.name || topic.trim() !== (room?.topic ?? '') || newAvatarMxc !== null;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
|
||||
<ArrowLeft size={22} color="#7c6bff" />
|
||||
</Pressable>
|
||||
<Text className="flex-1 text-foreground font-semibold text-base">Room Settings</Text>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={({ pressed }) =>
|
||||
`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className={`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size={14} color="#fff" />
|
||||
) : (
|
||||
<Text className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}>
|
||||
<Text
|
||||
className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Save
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerClassName="px-4 py-6 gap-8">
|
||||
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 24, gap: 32 }}>
|
||||
{/* Avatar */}
|
||||
<View className="items-center gap-3">
|
||||
<Pressable onPress={handlePickAvatar} disabled={uploadingAvatar}>
|
||||
|
|
@ -117,7 +125,11 @@ export default function RoomSettingsScreen() {
|
|||
{uploadingAvatar ? (
|
||||
<ActivityIndicator color="#7c6bff" />
|
||||
) : avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={{ width: 96, height: 96 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: avatarUri }}
|
||||
style={{ width: 96, height: 96 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground text-3xl font-bold">
|
||||
{room?.name?.[0]?.toUpperCase() ?? '#'}
|
||||
|
|
@ -165,7 +177,9 @@ export default function RoomSettingsScreen() {
|
|||
<View className="gap-2">
|
||||
<Text className="text-foreground text-sm font-semibold">Room ID</Text>
|
||||
<View className="bg-surface border border-border rounded-xl px-4 py-3">
|
||||
<Text className="text-muted-foreground text-sm font-mono" selectable>{id}</Text>
|
||||
<Text className="text-muted-foreground text-sm font-mono" selectable>
|
||||
{id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft, MagnifyingGlass, Lock, Users } from 'phosphor-react-native';
|
||||
|
|
@ -57,7 +49,7 @@ export default function SearchScreen() {
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[client, credentials],
|
||||
[client, credentials]
|
||||
);
|
||||
|
||||
const handleSearch = (text: string) => {
|
||||
|
|
@ -98,7 +90,11 @@ export default function SearchScreen() {
|
|||
{/* Avatar */}
|
||||
<View className="w-11 h-11 rounded-xl bg-surface border border-border overflow-hidden items-center justify-center shrink-0">
|
||||
{item.avatar_url ? (
|
||||
<Image source={{ uri: item.avatar_url }} style={{ width: 44, height: 44 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: item.avatar_url }}
|
||||
style={{ width: 44, height: 44 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
|
||||
)}
|
||||
|
|
@ -110,9 +106,7 @@ export default function SearchScreen() {
|
|||
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{item.join_rule === 'public' ? null : (
|
||||
<Lock size={11} color="#6b7280" />
|
||||
)}
|
||||
{item.join_rule === 'public' ? null : <Lock size={11} color="#6b7280" />}
|
||||
</View>
|
||||
{item.topic && (
|
||||
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={2}>
|
||||
|
|
@ -129,9 +123,7 @@ export default function SearchScreen() {
|
|||
<Pressable
|
||||
onPress={() => handleJoin(item)}
|
||||
disabled={isJoining}
|
||||
className={({ pressed }) =>
|
||||
`bg-primary rounded-lg px-3 py-1.5 shrink-0 ${pressed || isJoining ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="bg-primary rounded-lg px-3 py-1.5 shrink-0 active:opacity-60"
|
||||
>
|
||||
{isJoining ? (
|
||||
<ActivityIndicator size={14} color="#fff" />
|
||||
|
|
@ -147,10 +139,7 @@ export default function SearchScreen() {
|
|||
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
|
||||
<ArrowLeft size={22} color="#7c6bff" />
|
||||
</Pressable>
|
||||
<Text className="text-foreground text-lg font-semibold">Explore rooms</Text>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -38,18 +38,14 @@ export default function ImageViewer({ uri, onClose }: Props) {
|
|||
<View className="absolute top-0 left-0 right-0 z-10 flex-row justify-between p-4 pt-12">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
|
||||
>
|
||||
<X size={20} color="#fff" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed || saving ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
|
||||
>
|
||||
<DownloadSimple size={20} color="#fff" />
|
||||
</Pressable>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function AvatarCircle({
|
|||
);
|
||||
if (!onPress) return inner;
|
||||
return (
|
||||
<Pressable onPress={onPress} className={({ pressed }) => `${pressed ? 'opacity-60' : ''}`}>
|
||||
<Pressable onPress={onPress} className="active:opacity-60">
|
||||
{inner}
|
||||
</Pressable>
|
||||
);
|
||||
|
|
@ -113,17 +113,14 @@ function ReactionDetailsModal({
|
|||
<View className="flex-1 bg-background">
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Text className="text-foreground text-lg font-semibold">Reactions</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Pressable onPress={onClose} className="p-1 active:opacity-50">
|
||||
<Text className="text-primary text-base">Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
className="border-b border-border"
|
||||
contentContainerClassName="px-3 py-2 gap-2"
|
||||
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}
|
||||
>
|
||||
{reactions.map((r) => (
|
||||
<Pressable
|
||||
|
|
@ -144,7 +141,7 @@ function ReactionDetailsModal({
|
|||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
<ScrollView contentContainerClassName="py-2">
|
||||
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
|
||||
{selected?.users.map((userId) => (
|
||||
<View key={userId} className="flex-row items-center gap-3 px-4 py-2.5">
|
||||
<View className="w-8 h-8 rounded-full bg-surface border border-border items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -76,9 +76,13 @@ export default function MessageInput({
|
|||
{/* Context banner: Reply or Edit */}
|
||||
{(replyTo || isEditing) && (
|
||||
<View className="flex-row items-center gap-2 px-3 pt-2 pb-1">
|
||||
<View className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`} />
|
||||
<View
|
||||
className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}
|
||||
>
|
||||
{isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
|
||||
|
|
@ -87,7 +91,7 @@ export default function MessageInput({
|
|||
</View>
|
||||
<Pressable
|
||||
onPress={isEditing ? onCancelEdit : onCancelReply}
|
||||
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
|
||||
className="p-1 active:opacity-50"
|
||||
>
|
||||
<X size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
|
|
@ -99,9 +103,7 @@ export default function MessageInput({
|
|||
{onAttach && !isEditing && (
|
||||
<Pressable
|
||||
onPress={onAttach}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 items-center justify-center rounded-full ${pressed ? 'opacity-50' : ''}`
|
||||
}
|
||||
className="w-10 h-10 items-center justify-center rounded-full active:opacity-50"
|
||||
>
|
||||
<Paperclip size={20} color="#6b7280" />
|
||||
</Pressable>
|
||||
|
|
@ -126,9 +128,7 @@ export default function MessageInput({
|
|||
{showMic ? (
|
||||
<Pressable
|
||||
onPress={onVoiceRecord}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full items-center justify-center bg-surface border border-border ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="w-10 h-10 rounded-full items-center justify-center bg-surface border border-border active:opacity-60"
|
||||
>
|
||||
<Microphone size={20} color="#7c6bff" />
|
||||
</Pressable>
|
||||
|
|
@ -136,13 +136,13 @@ export default function MessageInput({
|
|||
<Pressable
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSend}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full items-center justify-center ${
|
||||
canSend
|
||||
? isEditing ? 'bg-yellow-500' : 'bg-primary'
|
||||
: 'bg-surface border border-border'
|
||||
} ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className={`w-10 h-10 rounded-full items-center justify-center ${
|
||||
canSend
|
||||
? isEditing
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-primary'
|
||||
: 'bg-surface border border-border'
|
||||
} active:opacity-60`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<PencilSimple size={16} weight="bold" color={canSend ? '#fff' : '#6b7280'} />
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ export default function RoomListItem({ room, onPress }: Props) {
|
|||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={({ pressed }) =>
|
||||
`flex-row items-center px-4 py-3 gap-3 ${pressed ? 'bg-surface/60' : ''}`
|
||||
}
|
||||
className="flex-row items-center px-4 py-3 gap-3 active:bg-surface/60"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View className="relative">
|
||||
|
|
@ -80,8 +78,8 @@ export default function RoomListItem({ room, onPress }: Props) {
|
|||
>
|
||||
{room.lastMessage
|
||||
? (room.lastMessageSender && !room.isDirect
|
||||
? `${room.lastMessageSender.split(':')[0].slice(1)}: `
|
||||
: '') + room.lastMessage
|
||||
? `${room.lastMessageSender.split(':')[0].slice(1)}: `
|
||||
: '') + room.lastMessage
|
||||
: room.isEncrypted
|
||||
? '🔒 Encrypted'
|
||||
: 'No messages'}
|
||||
|
|
@ -95,7 +93,11 @@ export default function RoomListItem({ room, onPress }: Props) {
|
|||
}`}
|
||||
>
|
||||
<Text className="text-white text-xs font-bold leading-none">
|
||||
{hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount}
|
||||
{hasHighlight
|
||||
? room.highlightCount
|
||||
: room.unreadCount > 99
|
||||
? '99+'
|
||||
: room.unreadCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,15 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
setLoading(true);
|
||||
setProfile(null);
|
||||
|
||||
client.getProfileInfo(userId)
|
||||
client
|
||||
.getProfileInfo(userId)
|
||||
.then((info) => {
|
||||
const rawAvatar = info.avatar_url ?? null;
|
||||
setProfile({
|
||||
userId,
|
||||
displayName: info.displayname ?? userId.split(':')[0].slice(1),
|
||||
avatarUrl: rawAvatar
|
||||
? resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined
|
||||
? (resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
})
|
||||
|
|
@ -50,9 +51,7 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
}, [userId]);
|
||||
|
||||
// Find an existing DM room with this user
|
||||
const existingDM = userId
|
||||
? rooms.find((r) => r.isDirect && r.dmUserId === userId)
|
||||
: null;
|
||||
const existingDM = userId ? rooms.find((r) => r.isDirect && r.dmUserId === userId) : null;
|
||||
|
||||
const handleStartDM = async () => {
|
||||
if (!client || !userId || !credentials) return;
|
||||
|
|
@ -81,12 +80,7 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
const initial = profile?.displayName[0]?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!userId}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Modal visible={!!userId} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable className="flex-1 bg-black/60" onPress={onClose}>
|
||||
<SafeAreaView className="flex-1 justify-end" edges={['bottom']}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()}>
|
||||
|
|
@ -98,12 +92,20 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
|
||||
{/* Close */}
|
||||
<View className="absolute top-3 right-4 z-10">
|
||||
<Pressable onPress={onClose} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
|
||||
<Pressable onPress={onClose} className="p-1 active:opacity-50">
|
||||
<X size={20} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerClassName="px-6 pt-4 pb-8 items-center gap-4">
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 32,
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#7c6bff" className="py-10" />
|
||||
) : profile ? (
|
||||
|
|
@ -111,7 +113,11 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
{/* Avatar */}
|
||||
<View className="w-24 h-24 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
|
||||
{profile.avatarUrl ? (
|
||||
<Image source={{ uri: profile.avatarUrl }} style={{ width: 96, height: 96 }} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: profile.avatarUrl }}
|
||||
style={{ width: 96, height: 96 }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-foreground text-4xl font-semibold">{initial}</Text>
|
||||
)}
|
||||
|
|
@ -119,17 +125,19 @@ export default function UserProfileModal({ userId, onClose }: Props) {
|
|||
|
||||
{/* Name */}
|
||||
<View className="items-center gap-1">
|
||||
<Text className="text-foreground text-xl font-bold">{profile.displayName}</Text>
|
||||
<Text className="text-muted-foreground text-sm" selectable>{profile.userId}</Text>
|
||||
<Text className="text-foreground text-xl font-bold">
|
||||
{profile.displayName}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm" selectable>
|
||||
{profile.userId}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
{profile.userId !== credentials?.userId && (
|
||||
<Pressable
|
||||
onPress={handleStartDM}
|
||||
className={({ pressed }) =>
|
||||
`flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 ${pressed ? 'opacity-70' : ''}`
|
||||
}
|
||||
className="flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 active:opacity-70"
|
||||
>
|
||||
<ChatCircle size={18} color="#fff" weight="fill" />
|
||||
<Text className="text-white font-semibold">
|
||||
|
|
|
|||
|
|
@ -47,9 +47,7 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) {
|
|||
<View className="flex-row items-center gap-3 px-3 py-2.5 min-w-[160px]">
|
||||
<Pressable
|
||||
onPress={handleToggle}
|
||||
className={({ pressed }) =>
|
||||
`w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`
|
||||
}
|
||||
className={`w-8 h-8 rounded-full items-center justify-center active:opacity-60 ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
|
||||
>
|
||||
{status.isBuffering ? (
|
||||
<ActivityIndicator size={14} color={iconColor} />
|
||||
|
|
|
|||
|
|
@ -94,9 +94,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
|
|||
{/* Discard */}
|
||||
<Pressable
|
||||
onPress={handleDiscard}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`
|
||||
}
|
||||
className="w-10 h-10 rounded-full bg-destructive/10 items-center justify-center active:opacity-60"
|
||||
>
|
||||
<Trash size={18} color="#ef4444" />
|
||||
</Pressable>
|
||||
|
|
@ -115,9 +113,7 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
|
|||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={sending || duration < 1}
|
||||
className={({ pressed }) =>
|
||||
`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} ${pressed || sending ? 'opacity-60' : ''}`
|
||||
}
|
||||
className={`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
|
||||
>
|
||||
<PaperPlaneRight size={18} color={duration >= 1 ? '#fff' : '#6b7280'} weight="fill" />
|
||||
</Pressable>
|
||||
|
|
|
|||
60
apps/mukke/CLAUDE.md
Normal file
60
apps/mukke/CLAUDE.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# CLAUDE.md - Mukke
|
||||
|
||||
Offline-first iOS Music Player. Songs aus iCloud/lokalen Dateien importieren, lokal auf dem Gerät speichern, abspielen.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/mukke/
|
||||
├── package.json # Orchestrator (name: mukke)
|
||||
├── apps/
|
||||
│ └── mobile/ # @mukke/mobile (Expo SDK 54)
|
||||
│ ├── app/ # Expo Router screens
|
||||
│ │ ├── (tabs)/ # 4 Tab-Screens (Bibliothek, Playlists, Suche, Settings)
|
||||
│ │ ├── player.tsx # Full-Screen Player (modal)
|
||||
│ │ ├── queue.tsx # Queue Ansicht (modal)
|
||||
│ │ ├── album/[id] # Album Detail
|
||||
│ │ ├── artist/[id] # Artist Detail
|
||||
│ │ └── playlist/ # Playlist Detail + New
|
||||
│ ├── components/ # UI components
|
||||
│ ├── contexts/ # AudioContext (expo-audio)
|
||||
│ ├── stores/ # Zustand stores (player, library, playlist)
|
||||
│ ├── services/ # Business logic (DB, import, audio, library, playlist)
|
||||
│ └── utils/ # Theme system
|
||||
└── packages/
|
||||
└── mukke-types/ # @mukke/types (shared interfaces)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:mukke:mobile # Start Expo app
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Audio**: expo-audio (background via UIBackgroundModes: ["audio"])
|
||||
- **Import**: expo-document-picker (iCloud + lokale Dateien)
|
||||
- **Storage**: expo-file-system (documentDirectory)
|
||||
- **Metadata**: @missingcore/audio-metadata (ID3v2.3/v2.4)
|
||||
- **DB**: expo-sqlite (SQLite für Songs, Playlists)
|
||||
- **State**: Zustand
|
||||
- **Navigation**: Expo Router + NativeTabs
|
||||
- **Styling**: NativeWind / Tailwind
|
||||
|
||||
## Architecture
|
||||
|
||||
- **No backend** - pure offline, local-only app
|
||||
- **SQLite** for structured data (songs, playlists, playlist_songs)
|
||||
- **Albums/Artists/Genres** derived from songs table via queries (no separate tables)
|
||||
- **File storage**: documentDirectory/music/ + documentDirectory/artwork/
|
||||
- **Audio playback**: expo-audio with background mode
|
||||
- **MiniPlayer**: persistent above tab bar
|
||||
|
||||
## Import Flow
|
||||
|
||||
1. User taps Import → expo-document-picker opens (iCloud + local)
|
||||
2. Files copied to documentDirectory/music/{uuid}.ext
|
||||
3. Metadata extracted via @missingcore/audio-metadata
|
||||
4. Cover art saved to documentDirectory/artwork/{uuid}.jpg
|
||||
5. Song entry created in SQLite
|
||||
6
apps/mukke/apps/mobile/.gitignore
vendored
Normal file
6
apps/mukke/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
53
apps/mukke/apps/mobile/app.json
Normal file
53
apps/mukke/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Mukke",
|
||||
"slug": "mukke",
|
||||
"version": "1.0.0",
|
||||
"scheme": "mukke",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router", "expo-sqlite"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.mana.mukke",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"]
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.mana.mukke"
|
||||
},
|
||||
"owner": "memoro",
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "placeholder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/mukke/apps/mobile/app/(tabs)/_layout.tsx
Normal file
33
apps/mukke/apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { MiniPlayer } from '~/components/MiniPlayer';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Icon sf="music.note.list" />
|
||||
<Label>Bibliothek</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="playlists">
|
||||
<Icon sf="list.bullet" />
|
||||
<Label>Playlists</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="search">
|
||||
<Icon sf="magnifyingglass" />
|
||||
<Label>Suche</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<Icon sf="gearshape.fill" />
|
||||
<Label>Einstellungen</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
<MiniPlayer />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
72
apps/mukke/apps/mobile/app/(tabs)/index.tsx
Normal file
72
apps/mukke/apps/mobile/app/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { AlbumGrid } from '~/components/AlbumGrid';
|
||||
import { ArtistList } from '~/components/ArtistList';
|
||||
import { GenreList } from '~/components/GenreList';
|
||||
import { ImportButton } from '~/components/ImportButton';
|
||||
import { SegmentedControl } from '~/components/SegmentedControl';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { SortMenu } from '~/components/SortMenu';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
import type { LibraryTab } from '~/types';
|
||||
|
||||
const SEGMENTS: { key: LibraryTab; label: string }[] = [
|
||||
{ key: 'songs', label: 'Songs' },
|
||||
{ key: 'albums', label: 'Alben' },
|
||||
{ key: 'artists', label: 'Künstler' },
|
||||
{ key: 'genres', label: 'Genres' },
|
||||
];
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const {
|
||||
songs,
|
||||
albums,
|
||||
artists,
|
||||
genres,
|
||||
activeTab,
|
||||
sortField,
|
||||
sortDirection,
|
||||
setActiveTab,
|
||||
setSortField,
|
||||
setSortDirection,
|
||||
loadAll,
|
||||
} = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Bibliothek',
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{activeTab === 'songs' && (
|
||||
<SortMenu
|
||||
currentField={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={(field, dir) => {
|
||||
setSortField(field);
|
||||
setSortDirection(dir);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ImportButton />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SegmentedControl segments={SEGMENTS} selected={activeTab} onSelect={setActiveTab} />
|
||||
|
||||
{activeTab === 'songs' && <SongList songs={songs} />}
|
||||
{activeTab === 'albums' && <AlbumGrid albums={albums} />}
|
||||
{activeTab === 'artists' && <ArtistList artists={artists} />}
|
||||
{activeTab === 'genres' && <GenreList genres={genres} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
70
apps/mukke/apps/mobile/app/(tabs)/playlists.tsx
Normal file
70
apps/mukke/apps/mobile/app/(tabs)/playlists.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { FlatList, Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { ListItem } from '~/components/ListItem';
|
||||
import { usePlaylistStore } from '~/stores/playlistStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { playlists, loadPlaylists } = usePlaylistStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Playlists',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => router.push('/playlist/new')} style={{ padding: 8 }}>
|
||||
<Ionicons name="add" size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{playlists.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="list-outline"
|
||||
title="Keine Playlists"
|
||||
message="Erstelle eine Playlist über den + Button."
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={playlists}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={item.description || undefined}
|
||||
left={
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={24} color={colors.primary} />
|
||||
</View>
|
||||
}
|
||||
onPress={() => router.push(`/playlist/${item.id}`)}
|
||||
showChevron
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
79
apps/mukke/apps/mobile/app/(tabs)/search.tsx
Normal file
79
apps/mukke/apps/mobile/app/(tabs)/search.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, TextInput } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { searchSongs } from '~/services/libraryService';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Song[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(async (text: string) => {
|
||||
setQuery(text);
|
||||
if (text.trim().length < 2) {
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
return;
|
||||
}
|
||||
setHasSearched(true);
|
||||
const songs = await searchSongs(text.trim());
|
||||
setResults(songs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen options={{ title: 'Suche' }} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
borderRadius: 10,
|
||||
margin: 16,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="search" size={18} color={colors.textTertiary} />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={handleSearch}
|
||||
placeholder="Songs, Künstler, Alben..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 8,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
}}
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!hasSearched ? (
|
||||
<EmptyState
|
||||
icon="search-outline"
|
||||
title="Suche"
|
||||
message="Suche nach Songs, Künstlern oder Alben."
|
||||
/>
|
||||
) : results.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="search-outline"
|
||||
title="Keine Ergebnisse"
|
||||
message={`Keine Treffer für "${query}".`}
|
||||
/>
|
||||
) : (
|
||||
<SongList songs={results} emptyTitle="Keine Ergebnisse" />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
187
apps/mukke/apps/mobile/app/(tabs)/settings.tsx
Normal file
187
apps/mukke/apps/mobile/app/(tabs)/settings.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Switch, ScrollView, Alert, Pressable } from 'react-native';
|
||||
|
||||
import { useTheme, type ThemeVariant } from '~/utils/themeContext';
|
||||
import { getStorageInfo, formatFileSize } from '~/services/fileService';
|
||||
import { getSongCount } from '~/services/libraryService';
|
||||
import { pickAndImportFiles } from '~/services/importService';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { colors, isDarkMode, toggleTheme, themeVariant, setThemeVariant } = useTheme();
|
||||
const loadAll = useLibraryStore((s) => s.loadAll);
|
||||
const [storageInfo, setStorageInfo] = useState({ musicSize: 0, artworkSize: 0, totalFiles: 0 });
|
||||
const [songCount, setSongCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadInfo();
|
||||
}, []);
|
||||
|
||||
const loadInfo = async () => {
|
||||
const [storage, count] = await Promise.all([getStorageInfo(), getSongCount()]);
|
||||
setStorageInfo(storage);
|
||||
setSongCount(count);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const songs = await pickAndImportFiles();
|
||||
if (songs.length > 0) {
|
||||
await loadAll();
|
||||
await loadInfo();
|
||||
Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
|
||||
}
|
||||
};
|
||||
|
||||
const variants: { key: ThemeVariant; label: string; color: string }[] = [
|
||||
{ key: 'classic', label: 'Orange', color: '#FF6B35' },
|
||||
{ key: 'ocean', label: 'Blau', color: '#2196F3' },
|
||||
{ key: 'sunset', label: 'Rot', color: '#FF6B6B' },
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<Stack.Screen options={{ title: 'Einstellungen' }} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Darstellung
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, color: colors.text }}>Dark Mode</Text>
|
||||
<Switch value={isDarkMode} onValueChange={toggleTheme} />
|
||||
</View>
|
||||
<View style={{ height: 0.5, backgroundColor: colors.border, marginLeft: 16 }} />
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ fontSize: 16, color: colors.text, marginBottom: 12 }}>Akzentfarbe</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
{variants.map((v) => (
|
||||
<Pressable
|
||||
key={v.key}
|
||||
onPress={() => setThemeVariant(v.key)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: v.color,
|
||||
borderWidth: themeVariant === v.key ? 3 : 0,
|
||||
borderColor: colors.text,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Import */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Musik
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleImport}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', padding: 16 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-circle-outline"
|
||||
size={22}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{ fontSize: 16, color: colors.primary }}>Songs importieren</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Storage */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Speicher
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Songs</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>{songCount}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Musik</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
|
||||
{formatFileSize(storageInfo.musicSize)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Cover Art</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>
|
||||
{formatFileSize(storageInfo.artworkSize)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 24,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Info
|
||||
</Text>
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 12, marginHorizontal: 16 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Version</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>1.0.0</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }}>Mukke</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary }}>Offline Music Player</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
16
apps/mukke/apps/mobile/app/+not-found.tsx
Normal file
16
apps/mukke/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className="items-center flex-1 justify-center p-5">
|
||||
<Text className="text-xl font-bold">Diese Seite existiert nicht.</Text>
|
||||
<Link href="/" className="mt-4 pt-4">
|
||||
<Text className="text-base text-[#2e78b7]">Zur Bibliothek</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
apps/mukke/apps/mobile/app/_layout.tsx
Normal file
78
apps/mukke/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import '../global.css';
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { AudioProvider } from '~/contexts/AudioContext';
|
||||
import { ThemeProvider } from '~/utils/themeContext';
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
{({ isDarkMode }) => (
|
||||
<ThemeWrapper>
|
||||
<AudioProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : 'transparent',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerTitleStyle: {
|
||||
color: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="player"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="queue"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
title: 'Warteschlange',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="album/[id]"
|
||||
options={{ title: 'Album', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="artist/[id]"
|
||||
options={{ title: 'Künstler', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="playlist/[id]"
|
||||
options={{ title: 'Playlist', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="playlist/new"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
title: 'Neue Playlist',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</AudioProvider>
|
||||
</ThemeWrapper>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
53
apps/mukke/apps/mobile/app/album/[id].tsx
Normal file
53
apps/mukke/apps/mobile/app/album/[id].tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { getSongsByAlbum } from '~/services/libraryService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const albumName = decodeURIComponent(id || '');
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumName) {
|
||||
getSongsByAlbum(albumName).then(setSongs);
|
||||
}
|
||||
}, [albumName]);
|
||||
|
||||
const coverArt = songs.find((s) => s.coverArtPath)?.coverArtPath || null;
|
||||
const artist = songs[0]?.albumArtist || songs[0]?.artist || 'Unbekannt';
|
||||
const year = songs[0]?.year;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Album Header */}
|
||||
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
|
||||
<Artwork uri={coverArt} size={180} />
|
||||
<Text
|
||||
style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{albumName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{artist}
|
||||
{year ? ` · ${year}` : ''} · {songs.length} Songs
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Keine Songs"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
47
apps/mukke/apps/mobile/app/artist/[id].tsx
Normal file
47
apps/mukke/apps/mobile/app/artist/[id].tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { getSongsByArtist } from '~/services/libraryService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function ArtistDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const artistName = decodeURIComponent(id || '');
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (artistName) {
|
||||
getSongsByArtist(artistName).then(setSongs);
|
||||
}
|
||||
}, [artistName]);
|
||||
|
||||
const albumCount = new Set(songs.map((s) => s.album).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Artist Header */}
|
||||
<View style={{ alignItems: 'center', padding: 20, paddingBottom: 8 }}>
|
||||
<Artwork uri={null} size={120} rounded />
|
||||
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text, marginTop: 12 }}>
|
||||
{artistName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{songs.length} Songs · {albumCount} Alben
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Keine Songs"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
102
apps/mukke/apps/mobile/app/player.tsx
Normal file
102
apps/mukke/apps/mobile/app/player.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { ProgressBar } from '~/components/ProgressBar';
|
||||
import { TransportControls } from '~/components/TransportControls';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlayerScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { seekTo } = useAudio();
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const position = usePlayerStore((s) => s.position);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const toggleFavorite = useLibraryStore((s) => s.toggleFavorite);
|
||||
|
||||
if (!currentSong) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: colors.textSecondary }}>Kein Song wird abgespielt</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} style={{ padding: 4 }}>
|
||||
<Ionicons name="chevron-down" size={28} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary, fontWeight: '600' }}>
|
||||
WIRD ABGESPIELT
|
||||
</Text>
|
||||
<Pressable onPress={() => router.push('/queue')} style={{ padding: 4 }}>
|
||||
<Ionicons name="list" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Artwork */}
|
||||
<View
|
||||
style={{ alignItems: 'center', paddingHorizontal: 40, flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
<Artwork uri={currentSong.coverArtPath} size={300} />
|
||||
</View>
|
||||
|
||||
{/* Song Info */}
|
||||
<View style={{ paddingHorizontal: 24, marginBottom: 16 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: '700', color: colors.text }} numberOfLines={1}>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 16, color: colors.textSecondary, marginTop: 4 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => toggleFavorite(currentSong.id)} style={{ padding: 8 }}>
|
||||
<Ionicons
|
||||
name={currentSong.favorite ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={currentSong.favorite ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress */}
|
||||
<ProgressBar position={position} duration={duration} onSeek={seekTo} />
|
||||
|
||||
{/* Transport */}
|
||||
<View style={{ paddingVertical: 24 }}>
|
||||
<TransportControls size="large" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
118
apps/mukke/apps/mobile/app/playlist/[id].tsx
Normal file
118
apps/mukke/apps/mobile/app/playlist/[id].tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Pressable, Alert } from 'react-native';
|
||||
|
||||
import { EmptyState } from '~/components/EmptyState';
|
||||
import { SongList } from '~/components/SongList';
|
||||
import { SongPicker } from '~/components/SongPicker';
|
||||
import {
|
||||
getPlaylistById,
|
||||
getPlaylistSongs,
|
||||
addSongToPlaylist,
|
||||
removeSongFromPlaylist,
|
||||
} from '~/services/playlistService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { Playlist, Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlaylistDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
const [playlist, setPlaylist] = useState<Playlist | null>(null);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!id) return;
|
||||
const [p, s] = await Promise.all([getPlaylistById(id), getPlaylistSongs(id)]);
|
||||
setPlaylist(p);
|
||||
setSongs(s);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const handleAddSongs = async (selected: Song[]) => {
|
||||
if (!id) return;
|
||||
for (const song of selected) {
|
||||
await addSongToPlaylist(id, song.id);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleLongPress = (song: Song) => {
|
||||
Alert.alert('Song entfernen', `"${song.title}" aus der Playlist entfernen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Entfernen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (id) {
|
||||
await removeSongFromPlaylist(id, song.id);
|
||||
await loadData();
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: playlist?.name || 'Playlist',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => setShowPicker(true)} style={{ padding: 8 }}>
|
||||
<Ionicons name="add" size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{playlist && (
|
||||
<View style={{ padding: 16, alignItems: 'center' }}>
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={{ fontSize: 20, fontWeight: '700', color: colors.text, marginTop: 12 }}>
|
||||
{playlist.name}
|
||||
</Text>
|
||||
{playlist.description && (
|
||||
<Text style={{ fontSize: 14, color: colors.textSecondary, marginTop: 4 }}>
|
||||
{playlist.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ fontSize: 13, color: colors.textTertiary, marginTop: 4 }}>
|
||||
{songs.length} Songs
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SongList
|
||||
songs={songs}
|
||||
onSongPress={(song, index) => playSong(song, songs, index)}
|
||||
emptyTitle="Playlist ist leer"
|
||||
emptyMessage="Füge Songs über den + Button hinzu."
|
||||
/>
|
||||
|
||||
<SongPicker
|
||||
visible={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddSongs}
|
||||
excludeIds={songs.map((s) => s.id)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
84
apps/mukke/apps/mobile/app/playlist/new.tsx
Normal file
84
apps/mukke/apps/mobile/app/playlist/new.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable } from 'react-native';
|
||||
|
||||
import { usePlaylistStore } from '~/stores/playlistStore';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function NewPlaylistScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
const playlist = await createPlaylist(name.trim(), description.trim() || undefined);
|
||||
router.dismiss();
|
||||
router.push(`/playlist/${playlist.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background, padding: 20 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Playlist Name"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, color: colors.textSecondary, marginBottom: 8 }}>
|
||||
Beschreibung (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Beschreibung..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
marginBottom: 32,
|
||||
minHeight: 80,
|
||||
}}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={handleCreate}
|
||||
disabled={!name.trim()}
|
||||
style={{
|
||||
backgroundColor: name.trim() ? colors.primary : colors.backgroundTertiary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: name.trim() ? '#FFFFFF' : colors.textTertiary,
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
77
apps/mukke/apps/mobile/app/queue.tsx
Normal file
77
apps/mukke/apps/mobile/app/queue.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Text, FlatList } from 'react-native';
|
||||
|
||||
import { Artwork } from '~/components/Artwork';
|
||||
import { ListItem } from '~/components/ListItem';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function QueueScreen() {
|
||||
const { colors } = useTheme();
|
||||
const queue = usePlayerStore((s) => s.getQueue());
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
const currentIndex = queue.findIndex((s) => s.id === currentSong?.id);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{currentSong && (
|
||||
<View style={{ padding: 16, borderBottomWidth: 0.5, borderBottomColor: colors.border }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
AKTUELLER SONG
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Artwork uri={currentSong.coverArtPath} size={48} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
ALS NÄCHSTES
|
||||
</Text>
|
||||
|
||||
<FlatList
|
||||
data={queue.slice(currentIndex + 1)}
|
||||
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
renderItem={({ item, index }) => (
|
||||
<ListItem
|
||||
title={item.title}
|
||||
subtitle={item.artist || 'Unbekannt'}
|
||||
trailing={formatDuration(item.duration)}
|
||||
left={<Artwork uri={item.coverArtPath} size={40} />}
|
||||
onPress={() => playSong(item, queue, currentIndex + 1 + index)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
BIN
apps/mukke/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/mukke/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/mukke/apps/mobile/assets/favicon.png
Normal file
BIN
apps/mukke/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 B |
BIN
apps/mukke/apps/mobile/assets/icon.png
Normal file
BIN
apps/mukke/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/mukke/apps/mobile/assets/splash.png
Normal file
BIN
apps/mukke/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
10
apps/mukke/apps/mobile/babel.config.js
Normal file
10
apps/mukke/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
75
apps/mukke/apps/mobile/components/AlbumGrid.tsx
Normal file
75
apps/mukke/apps/mobile/components/AlbumGrid.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FlatList, Image, Pressable, Text, View, useWindowDimensions } from 'react-native';
|
||||
|
||||
import type { Album } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
interface AlbumGridProps {
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export function AlbumGrid({ albums }: AlbumGridProps) {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemSize = (width - 48) / 2;
|
||||
|
||||
if (albums.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="disc-outline"
|
||||
title="Keine Alben"
|
||||
message="Importierte Songs werden nach Alben gruppiert."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={albums}
|
||||
keyExtractor={(item) => item.name}
|
||||
numColumns={2}
|
||||
contentContainerStyle={{ padding: 12, paddingBottom: 100 }}
|
||||
columnWrapperStyle={{ gap: 12 }}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => router.push(`/album/${encodeURIComponent(item.name)}`)}
|
||||
style={{ width: itemSize }}
|
||||
>
|
||||
{item.coverArtPath ? (
|
||||
<Image
|
||||
source={{ uri: item.coverArtPath }}
|
||||
style={{ width: itemSize, height: itemSize, borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="disc-outline" size={48} color={colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={{ fontSize: 14, fontWeight: '600', color: colors.text, marginTop: 6 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{item.artist || 'Unbekannt'} · {item.songCount} Songs
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
apps/mukke/apps/mobile/components/ArtistList.tsx
Normal file
43
apps/mukke/apps/mobile/components/ArtistList.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import type { Artist } from '~/types';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface ArtistListProps {
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export function ArtistList({ artists }: ArtistListProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (artists.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="person-outline"
|
||||
title="Keine Künstler"
|
||||
message="Importierte Songs werden nach Künstlern gruppiert."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={artists}
|
||||
keyExtractor={(item) => item.name}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={`${item.songCount} Songs · ${item.albumCount} Alben`}
|
||||
left={<Artwork uri={null} size={44} rounded />}
|
||||
onPress={() => router.push(`/artist/${encodeURIComponent(item.name)}`)}
|
||||
showChevron
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
apps/mukke/apps/mobile/components/Artwork.tsx
Normal file
42
apps/mukke/apps/mobile/components/Artwork.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ArtworkProps {
|
||||
uri: string | null | undefined;
|
||||
size?: number;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export function Artwork({ uri, size = 48, rounded = false }: ArtworkProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
if (uri) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: rounded ? size / 2 : 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: rounded ? size / 2 : 8,
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-note" size={size * 0.4} color={colors.textTertiary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
45
apps/mukke/apps/mobile/components/Button.tsx
Normal file
45
apps/mukke/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Pressable, Text, ActivityIndicator } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ title, onPress, variant = 'primary', loading, disabled }: ButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const bgColor =
|
||||
variant === 'primary'
|
||||
? colors.primary
|
||||
: variant === 'secondary'
|
||||
? colors.backgroundTertiary
|
||||
: 'transparent';
|
||||
|
||||
const textColor = variant === 'primary' ? '#FFFFFF' : colors.text;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={textColor} size="small" />
|
||||
) : (
|
||||
<Text style={{ color: textColor, fontWeight: '600', fontSize: 16 }}>{title}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
43
apps/mukke/apps/mobile/components/EmptyState.tsx
Normal file
43
apps/mukke/apps/mobile/components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = 'musical-notes-outline', title, message }: EmptyStateProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<Ionicons name={icon} size={64} color={colors.textTertiary} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{message && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
65
apps/mukke/apps/mobile/components/GenreList.tsx
Normal file
65
apps/mukke/apps/mobile/components/GenreList.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlatList, View } from 'react-native';
|
||||
|
||||
import type { Genre } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { getSongsByGenre } from '~/services/libraryService';
|
||||
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface GenreListProps {
|
||||
genres: Genre[];
|
||||
}
|
||||
|
||||
export function GenreList({ genres }: GenreListProps) {
|
||||
const { colors } = useTheme();
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
if (genres.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="albums-outline"
|
||||
title="Keine Genres"
|
||||
message="Genre-Tags werden beim Import aus den Metadaten gelesen."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePress = async (genre: Genre) => {
|
||||
const songs = await getSongsByGenre(genre.name);
|
||||
if (songs.length > 0) {
|
||||
playSong(songs[0], songs, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={genres}
|
||||
keyExtractor={(item) => item.name}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.name}
|
||||
subtitle={`${item.songCount} Songs`}
|
||||
left={
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes" size={22} color={colors.primary} />
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
apps/mukke/apps/mobile/components/ImportButton.tsx
Normal file
36
apps/mukke/apps/mobile/components/ImportButton.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Alert } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { pickAndImportFiles } from '~/services/importService';
|
||||
import { useLibraryStore } from '~/stores/libraryStore';
|
||||
|
||||
export function ImportButton() {
|
||||
const { colors } = useTheme();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const loadAll = useLibraryStore((s) => s.loadAll);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (importing) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const songs = await pickAndImportFiles();
|
||||
if (songs.length > 0) {
|
||||
await loadAll();
|
||||
Alert.alert('Importiert', `${songs.length} Song${songs.length > 1 ? 's' : ''} importiert.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
Alert.alert('Fehler', 'Beim Import ist ein Fehler aufgetreten.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable onPress={handleImport} disabled={importing} style={{ padding: 8 }}>
|
||||
<Ionicons name={importing ? 'hourglass' : 'add'} size={28} color={colors.primary} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
66
apps/mukke/apps/mobile/components/ListItem.tsx
Normal file
66
apps/mukke/apps/mobile/components/ListItem.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface ListItemProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
trailing?: string;
|
||||
left?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
showChevron?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
left,
|
||||
onPress,
|
||||
onLongPress,
|
||||
showChevron,
|
||||
}: ListItemProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: pressed ? colors.backgroundTertiary : 'transparent',
|
||||
})}
|
||||
>
|
||||
{left && <View style={{ marginRight: 12 }}>{left}</View>}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 16, color: colors.text }} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{ fontSize: 13, color: colors.textSecondary, marginTop: 2 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{trailing && (
|
||||
<Text style={{ fontSize: 13, color: colors.textTertiary, marginLeft: 8 }}>{trailing}</Text>
|
||||
)}
|
||||
{showChevron && (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={18}
|
||||
color={colors.textTertiary}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
77
apps/mukke/apps/mobile/components/MiniPlayer.tsx
Normal file
77
apps/mukke/apps/mobile/components/MiniPlayer.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { play, pause, playNext } = useAudio();
|
||||
const currentSong = usePlayerStore((s) => s.currentSong);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const position = usePlayerStore((s) => s.position);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
|
||||
if (!currentSong) return null;
|
||||
|
||||
const progress = duration > 0 ? position / duration : 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push('/player')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 49,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.border,
|
||||
}}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<View style={{ height: 2, backgroundColor: colors.backgroundTertiary }}>
|
||||
<View
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: colors.primary,
|
||||
width: `${progress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Artwork uri={currentSong.coverArtPath} size={40} />
|
||||
|
||||
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: '600', color: colors.text }} numberOfLines={1}>
|
||||
{currentSong.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{currentSong.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={isPlaying ? pause : play} style={{ padding: 8 }}>
|
||||
<Ionicons name={isPlaying ? 'pause' : 'play'} size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={playNext} style={{ padding: 8 }}>
|
||||
<Ionicons name="play-skip-forward" size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
37
apps/mukke/apps/mobile/components/ProgressBar.tsx
Normal file
37
apps/mukke/apps/mobile/components/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
|
||||
interface ProgressBarProps {
|
||||
position: number;
|
||||
duration: number;
|
||||
onSeek: (position: number) => void;
|
||||
}
|
||||
|
||||
export function ProgressBar({ position, duration, onSeek }: ProgressBarProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ width: '100%', paddingHorizontal: 20 }}>
|
||||
<Slider
|
||||
value={duration > 0 ? position / duration : 0}
|
||||
onSlidingComplete={(value) => onSeek(value * duration)}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
minimumTrackTintColor={colors.primary}
|
||||
maximumTrackTintColor={colors.backgroundTertiary}
|
||||
thumbTintColor={colors.primary}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
{formatDuration(position)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
-{formatDuration(Math.max(0, duration - position))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
57
apps/mukke/apps/mobile/components/SegmentedControl.tsx
Normal file
57
apps/mukke/apps/mobile/components/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
segments: { key: T; label: string }[];
|
||||
selected: T;
|
||||
onSelect: (key: T) => void;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
segments,
|
||||
selected,
|
||||
onSelect,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
}}
|
||||
>
|
||||
{segments.map((seg) => {
|
||||
const isActive = seg.key === selected;
|
||||
return (
|
||||
<Pressable
|
||||
key={seg.key}
|
||||
onPress={() => onSelect(seg.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: isActive ? colors.card : 'transparent',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
color: isActive ? colors.text : colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{seg.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
54
apps/mukke/apps/mobile/components/SongList.tsx
Normal file
54
apps/mukke/apps/mobile/components/SongList.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { FlatList } from 'react-native';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
import { formatDuration } from '~/services/audioService';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
onSongPress?: (song: Song, index: number) => void;
|
||||
emptyTitle?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function SongList({
|
||||
songs,
|
||||
onSongPress,
|
||||
emptyTitle = 'Keine Songs',
|
||||
emptyMessage = 'Importiere Songs über den + Button.',
|
||||
}: SongListProps) {
|
||||
const playSong = usePlayerStore((s) => s.playSong);
|
||||
|
||||
const handlePress = (song: Song, index: number) => {
|
||||
if (onSongPress) {
|
||||
onSongPress(song, index);
|
||||
} else {
|
||||
playSong(song, songs, index);
|
||||
}
|
||||
};
|
||||
|
||||
if (songs.length === 0) {
|
||||
return <EmptyState title={emptyTitle} message={emptyMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={songs}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<ListItem
|
||||
title={item.title}
|
||||
subtitle={[item.artist, item.album].filter(Boolean).join(' · ')}
|
||||
trailing={formatDuration(item.duration)}
|
||||
left={<Artwork uri={item.coverArtPath} size={44} />}
|
||||
onPress={() => handlePress(item, index)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
110
apps/mukke/apps/mobile/components/SongPicker.tsx
Normal file
110
apps/mukke/apps/mobile/components/SongPicker.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FlatList, Pressable, View, Text, Modal } from 'react-native';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { getAllSongs } from '~/services/libraryService';
|
||||
|
||||
import { Artwork } from './Artwork';
|
||||
|
||||
interface SongPickerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (songs: Song[]) => void;
|
||||
excludeIds?: string[];
|
||||
}
|
||||
|
||||
export function SongPicker({ visible, onClose, onSelect, excludeIds = [] }: SongPickerProps) {
|
||||
const { colors } = useTheme();
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
getAllSongs().then((all) => {
|
||||
setSongs(all.filter((s) => !excludeIds.includes(s.id)));
|
||||
});
|
||||
setSelected(new Set());
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
const selectedSongs = songs.filter((s) => selected.has(s.id));
|
||||
onSelect(selectedSongs);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text style={{ fontSize: 16, color: colors.primary }}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 17, fontWeight: '600', color: colors.text }}>
|
||||
Songs auswählen
|
||||
</Text>
|
||||
<Pressable onPress={handleDone}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.primary }}>
|
||||
Fertig ({selected.size})
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={songs}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = selected.has(item.id);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => toggleSelection(item.id)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isSelected ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={24}
|
||||
color={isSelected ? colors.primary : colors.textTertiary}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Artwork uri={item.coverArtPath} size={40} />
|
||||
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||
<Text style={{ fontSize: 15, color: colors.text }} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{item.artist || 'Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
95
apps/mukke/apps/mobile/components/SortMenu.tsx
Normal file
95
apps/mukke/apps/mobile/components/SortMenu.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, View, Text, Modal } from 'react-native';
|
||||
|
||||
import type { SortField, SortDirection } from '~/types';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
const SORT_OPTIONS: { field: SortField; label: string }[] = [
|
||||
{ field: 'title', label: 'Titel' },
|
||||
{ field: 'artist', label: 'Künstler' },
|
||||
{ field: 'album', label: 'Album' },
|
||||
{ field: 'addedAt', label: 'Hinzugefügt' },
|
||||
{ field: 'playCount', label: 'Wiedergaben' },
|
||||
];
|
||||
|
||||
interface SortMenuProps {
|
||||
currentField: SortField;
|
||||
currentDirection: SortDirection;
|
||||
onSort: (field: SortField, direction: SortDirection) => void;
|
||||
}
|
||||
|
||||
export function SortMenu({ currentField, currentDirection, onSort }: SortMenuProps) {
|
||||
const { colors } = useTheme();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable onPress={() => setVisible(true)} style={{ padding: 8 }}>
|
||||
<Ionicons name="swap-vertical" size={22} color={colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<Pressable
|
||||
onPress={() => setVisible(false)}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text, marginBottom: 16 }}>
|
||||
Sortieren
|
||||
</Text>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const isActive = opt.field === currentField;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.field}
|
||||
onPress={() => {
|
||||
if (isActive) {
|
||||
onSort(opt.field, currentDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
onSort(opt.field, 'asc');
|
||||
}
|
||||
setVisible(false);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: isActive ? colors.primary : colors.text,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<Ionicons
|
||||
name={currentDirection === 'asc' ? 'arrow-up' : 'arrow-down'}
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
apps/mukke/apps/mobile/components/ThemeWrapper.tsx
Normal file
20
apps/mukke/apps/mobile/components/ThemeWrapper.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
type ThemeWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ThemeWrapper: React.FC<ThemeWrapperProps> = ({ children, className = '' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`${isDarkMode ? 'dark bg-background-dark' : 'bg-background-light'} flex-1 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
104
apps/mukke/apps/mobile/components/TransportControls.tsx
Normal file
104
apps/mukke/apps/mobile/components/TransportControls.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Pressable } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
import { useAudio } from '~/contexts/AudioContext';
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import type { RepeatMode, ShuffleMode } from '~/types';
|
||||
|
||||
interface TransportControlsProps {
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
function getRepeatIcon(mode: RepeatMode): keyof typeof Ionicons.glyphMap {
|
||||
if (mode === 'one') return 'repeat';
|
||||
return 'repeat';
|
||||
}
|
||||
|
||||
export function TransportControls({ size = 'large' }: TransportControlsProps) {
|
||||
const { colors } = useTheme();
|
||||
const { play, pause, playNext, playPrevious } = useAudio();
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const repeatMode = usePlayerStore((s) => s.repeatMode);
|
||||
const shuffleMode = usePlayerStore((s) => s.shuffleMode);
|
||||
const toggleRepeat = usePlayerStore((s) => s.toggleRepeat);
|
||||
const toggleShuffle = usePlayerStore((s) => s.toggleShuffle);
|
||||
|
||||
const iconSize = size === 'large' ? 36 : 24;
|
||||
const playSize = size === 'large' ? 56 : 32;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: size === 'large' ? 32 : 20,
|
||||
}}
|
||||
>
|
||||
{size === 'large' && (
|
||||
<Pressable onPress={toggleShuffle}>
|
||||
<Ionicons
|
||||
name="shuffle"
|
||||
size={24}
|
||||
color={shuffleMode === 'on' ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable onPress={playPrevious}>
|
||||
<Ionicons name="play-skip-back" size={iconSize} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={isPlaying ? pause : play}
|
||||
style={{
|
||||
width: playSize,
|
||||
height: playSize,
|
||||
borderRadius: playSize / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
size={playSize * 0.5}
|
||||
color="#FFFFFF"
|
||||
style={{ marginLeft: isPlaying ? 0 : 2 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={playNext}>
|
||||
<Ionicons name="play-skip-forward" size={iconSize} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
{size === 'large' && (
|
||||
<Pressable onPress={toggleRepeat}>
|
||||
<Ionicons
|
||||
name={getRepeatIcon(repeatMode)}
|
||||
size={24}
|
||||
color={repeatMode !== 'off' ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
{repeatMode === 'one' && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -6,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="remove" size={8} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
140
apps/mukke/apps/mobile/contexts/AudioContext.tsx
Normal file
140
apps/mukke/apps/mobile/contexts/AudioContext.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import { usePlayerStore } from '~/stores/playerStore';
|
||||
import { updatePlayStats, updateSongDuration } from '~/services/libraryService';
|
||||
|
||||
interface AudioContextType {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seekTo: (position: number) => void;
|
||||
playNext: () => void;
|
||||
playPrevious: () => void;
|
||||
}
|
||||
|
||||
const AudioCtx = createContext<AudioContextType>({
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seekTo: () => {},
|
||||
playNext: () => {},
|
||||
playPrevious: () => {},
|
||||
});
|
||||
|
||||
export const useAudio = () => useContext(AudioCtx);
|
||||
|
||||
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const player = useAudioPlayer(null);
|
||||
const status = useAudioPlayerStatus(player);
|
||||
const { currentSong, isPlaying, setPlaying, setPosition, setDuration, nextSong, previousSong } =
|
||||
usePlayerStore();
|
||||
const hasCountedPlay = useRef(false);
|
||||
const lastSongId = useRef<string | null>(null);
|
||||
|
||||
// Configure audio mode for background playback
|
||||
useEffect(() => {
|
||||
setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load song when currentSong changes
|
||||
useEffect(() => {
|
||||
if (!currentSong) return;
|
||||
if (lastSongId.current === currentSong.id) return;
|
||||
lastSongId.current = currentSong.id;
|
||||
hasCountedPlay.current = false;
|
||||
|
||||
player.replace({ uri: currentSong.filePath });
|
||||
|
||||
// Set lock screen metadata
|
||||
player.setActiveForLockScreen(true, {
|
||||
title: currentSong.title,
|
||||
artist: currentSong.artist || undefined,
|
||||
albumTitle: currentSong.album || undefined,
|
||||
artworkSource: currentSong.coverArtPath ? { uri: currentSong.coverArtPath } : undefined,
|
||||
});
|
||||
|
||||
player.play();
|
||||
}, [currentSong?.id]);
|
||||
|
||||
// Sync play/pause state
|
||||
useEffect(() => {
|
||||
if (!currentSong) return;
|
||||
if (isPlaying && !status.playing) {
|
||||
player.play();
|
||||
} else if (!isPlaying && status.playing) {
|
||||
player.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Update position and duration from player status
|
||||
useEffect(() => {
|
||||
if (status.currentTime !== undefined) {
|
||||
setPosition(status.currentTime);
|
||||
}
|
||||
if (status.duration && status.duration > 0) {
|
||||
setDuration(status.duration);
|
||||
// Save duration to DB if not yet stored
|
||||
if (currentSong && !currentSong.duration) {
|
||||
updateSongDuration(currentSong.id, status.duration);
|
||||
}
|
||||
}
|
||||
}, [status.currentTime, status.duration]);
|
||||
|
||||
// Count play after 10 seconds
|
||||
useEffect(() => {
|
||||
if (currentSong && status.currentTime > 10 && !hasCountedPlay.current) {
|
||||
hasCountedPlay.current = true;
|
||||
updatePlayStats(currentSong.id);
|
||||
}
|
||||
}, [status.currentTime]);
|
||||
|
||||
// Auto-advance when track ends
|
||||
useEffect(() => {
|
||||
if (status.didJustFinish) {
|
||||
const next = nextSong();
|
||||
if (!next) {
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [status.didJustFinish]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
player.play();
|
||||
setPlaying(true);
|
||||
}, [player]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
player.pause();
|
||||
setPlaying(false);
|
||||
}, [player]);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(position: number) => {
|
||||
player.seekTo(position);
|
||||
setPosition(position);
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const playNext = useCallback(() => {
|
||||
const song = nextSong();
|
||||
if (!song) setPlaying(false);
|
||||
}, []);
|
||||
|
||||
const playPrevious = useCallback(() => {
|
||||
const song = previousSong();
|
||||
if (song && song.id === currentSong?.id) {
|
||||
// Restart current song
|
||||
player.seekTo(0);
|
||||
setPosition(0);
|
||||
}
|
||||
}, [currentSong?.id, player]);
|
||||
|
||||
return (
|
||||
<AudioCtx.Provider value={{ play, pause, seekTo, playNext, playPrevious }}>
|
||||
{children}
|
||||
</AudioCtx.Provider>
|
||||
);
|
||||
};
|
||||
38
apps/mukke/apps/mobile/eas.json
Normal file
38
apps/mukke/apps/mobile/eas.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.17.4",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "22.15.0",
|
||||
"pnpm": "10.18.1",
|
||||
"env": {
|
||||
"PNPM_WORKSPACE_ROOT": "../..",
|
||||
"EAS_BUILD": "true"
|
||||
},
|
||||
"cache": {
|
||||
"disabled": false,
|
||||
"key": "v1",
|
||||
"cacheDefaultPaths": true,
|
||||
"customPaths": ["node_modules", "../../node_modules"]
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"extends": "base",
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
3
apps/mukke/apps/mobile/global.css
Normal file
3
apps/mukke/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
25
apps/mukke/apps/mobile/metro.config.js
Normal file
25
apps/mukke/apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
const path = require('path');
|
||||
|
||||
// Get the project and workspace root directories
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, '../../../..');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// Watch all files within the monorepo (needed for workspace packages like @mukke/types)
|
||||
config.watchFolders = [path.resolve(projectRoot, '../../packages'), monorepoRoot + '/node_modules'];
|
||||
|
||||
// Let Metro know where to resolve packages and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(monorepoRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// Support .cjs and .mjs extensions
|
||||
config.resolver.sourceExts = [...config.resolver.sourceExts, 'cjs', 'mjs'];
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
3
apps/mukke/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/mukke/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
66
apps/mukke/apps/mobile/package.json
Normal file
66
apps/mukke/apps/mobile/package.json
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"name": "@mukke/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@missingcore/audio-metadata": "^1.3.0",
|
||||
"@mukke/types": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "5.1.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "~55.0.0",
|
||||
"expo-audio": "~55.0.0",
|
||||
"expo-constants": "~55.0.0",
|
||||
"expo-dev-client": "~55.0.0",
|
||||
"expo-dev-launcher": "~55.0.0",
|
||||
"expo-document-picker": "~55.0.0",
|
||||
"expo-file-system": "~55.0.0",
|
||||
"expo-router": "~55.0.0",
|
||||
"expo-sqlite": "~55.0.0",
|
||||
"expo-status-bar": "~55.0.0",
|
||||
"expo-system-ui": "~55.0.0",
|
||||
"nativewind": "^4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "~4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "~0.7.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-universe": "^14.0.0",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "universe/native",
|
||||
"root": true
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
17
apps/mukke/apps/mobile/services/audioService.ts
Normal file
17
apps/mukke/apps/mobile/services/audioService.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { setAudioModeAsync } from 'expo-audio';
|
||||
|
||||
export { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
|
||||
export async function configureAudioMode(): Promise<void> {
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null | undefined): string {
|
||||
if (!seconds || seconds <= 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
67
apps/mukke/apps/mobile/services/database.ts
Normal file
67
apps/mukke/apps/mobile/services/database.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (db) return db;
|
||||
db = await SQLite.openDatabaseAsync('mukke.db');
|
||||
await initializeDatabase(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
async function initializeDatabase(database: SQLite.SQLiteDatabase): Promise<void> {
|
||||
await database.execAsync(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
albumArtist TEXT,
|
||||
genre TEXT,
|
||||
trackNumber INTEGER,
|
||||
discNumber INTEGER,
|
||||
year INTEGER,
|
||||
duration REAL,
|
||||
filePath TEXT NOT NULL,
|
||||
fileSize INTEGER,
|
||||
coverArtPath TEXT,
|
||||
addedAt TEXT NOT NULL,
|
||||
lastPlayedAt TEXT,
|
||||
playCount INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
coverArtPath TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
playlistId TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
songId TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
sortOrder INTEGER NOT NULL,
|
||||
addedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_favorite ON songs(favorite);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlistId);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (db) {
|
||||
await db.closeAsync();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
102
apps/mukke/apps/mobile/services/fileService.ts
Normal file
102
apps/mukke/apps/mobile/services/fileService.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MUSIC_DIR = `${FileSystem.documentDirectory}music/`;
|
||||
const ARTWORK_DIR = `${FileSystem.documentDirectory}artwork/`;
|
||||
|
||||
export async function ensureDirectories(): Promise<void> {
|
||||
const musicInfo = await FileSystem.getInfoAsync(MUSIC_DIR);
|
||||
if (!musicInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(MUSIC_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
const artworkInfo = await FileSystem.getInfoAsync(ARTWORK_DIR);
|
||||
if (!artworkInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(ARTWORK_DIR, { intermediates: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(uri: string): string {
|
||||
const parts = uri.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'mp3';
|
||||
}
|
||||
|
||||
export async function copyToMusicDirectory(
|
||||
sourceUri: string
|
||||
): Promise<{ path: string; id: string }> {
|
||||
await ensureDirectories();
|
||||
const id = uuidv4();
|
||||
const ext = getFileExtension(sourceUri);
|
||||
const destPath = `${MUSIC_DIR}${id}.${ext}`;
|
||||
await FileSystem.copyAsync({ from: sourceUri, to: destPath });
|
||||
return { path: destPath, id };
|
||||
}
|
||||
|
||||
export async function saveArtwork(data: Uint8Array, songId: string): Promise<string> {
|
||||
await ensureDirectories();
|
||||
const artworkPath = `${ARTWORK_DIR}${songId}.jpg`;
|
||||
const base64 = uint8ArrayToBase64(data);
|
||||
await FileSystem.writeAsStringAsync(artworkPath, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStorageInfo(): Promise<{
|
||||
musicSize: number;
|
||||
artworkSize: number;
|
||||
totalFiles: number;
|
||||
}> {
|
||||
let musicSize = 0;
|
||||
let artworkSize = 0;
|
||||
let totalFiles = 0;
|
||||
|
||||
try {
|
||||
const musicFiles = await FileSystem.readDirectoryAsync(MUSIC_DIR);
|
||||
for (const file of musicFiles) {
|
||||
const info = await FileSystem.getInfoAsync(`${MUSIC_DIR}${file}`);
|
||||
if (info.exists && !info.isDirectory && 'size' in info) {
|
||||
musicSize += info.size ?? 0;
|
||||
totalFiles++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
try {
|
||||
const artworkFiles = await FileSystem.readDirectoryAsync(ARTWORK_DIR);
|
||||
for (const file of artworkFiles) {
|
||||
const info = await FileSystem.getInfoAsync(`${ARTWORK_DIR}${file}`);
|
||||
if (info.exists && !info.isDirectory && 'size' in info) {
|
||||
artworkSize += info.size ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
return { musicSize, artworkSize, totalFiles };
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
104
apps/mukke/apps/mobile/services/importService.ts
Normal file
104
apps/mukke/apps/mobile/services/importService.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { getAudioMetadata } from '@missingcore/audio-metadata';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
import type { Song } from '~/types';
|
||||
|
||||
import { copyToMusicDirectory, saveArtwork } from './fileService';
|
||||
import { insertSong } from './libraryService';
|
||||
|
||||
const SUPPORTED_TYPES = ['audio/*'];
|
||||
|
||||
export async function pickAndImportFiles(): Promise<Song[]> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: SUPPORTED_TYPES,
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const importedSongs: Song[] = [];
|
||||
|
||||
for (const asset of result.assets) {
|
||||
try {
|
||||
const song = await importSingleFile(asset);
|
||||
if (song) {
|
||||
importedSongs.push(song);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to import ${asset.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return importedSongs;
|
||||
}
|
||||
|
||||
async function importSingleFile(asset: DocumentPicker.DocumentPickerAsset): Promise<Song | null> {
|
||||
// Copy to permanent storage
|
||||
const { path: filePath, id } = await copyToMusicDirectory(asset.uri);
|
||||
|
||||
// Get file size
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
const fileSize = fileInfo.exists && 'size' in fileInfo ? (fileInfo.size ?? null) : null;
|
||||
|
||||
// Extract metadata
|
||||
let metadata: Awaited<ReturnType<typeof getAudioMetadata>> | null = null;
|
||||
try {
|
||||
metadata = await getAudioMetadata(filePath, [
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'albumArtist',
|
||||
'genre',
|
||||
'trackNumber',
|
||||
'year',
|
||||
'picture',
|
||||
]);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read metadata:', error);
|
||||
}
|
||||
|
||||
// Save cover art if available
|
||||
let coverArtPath: string | null = null;
|
||||
if (metadata?.metadata?.picture) {
|
||||
try {
|
||||
const pictureData = metadata.metadata.picture;
|
||||
if (pictureData && typeof pictureData === 'object' && 'data' in pictureData) {
|
||||
coverArtPath = await saveArtwork(pictureData.data as Uint8Array, id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save cover art:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Build title from metadata or filename
|
||||
const title = (metadata?.metadata?.title as string) || asset.name.replace(/\.[^.]+$/, '');
|
||||
|
||||
const song: Song = {
|
||||
id,
|
||||
title,
|
||||
artist: (metadata?.metadata?.artist as string) || null,
|
||||
album: (metadata?.metadata?.album as string) || null,
|
||||
albumArtist: (metadata?.metadata?.albumArtist as string) || null,
|
||||
genre: (metadata?.metadata?.genre as string) || null,
|
||||
trackNumber: metadata?.metadata?.trackNumber
|
||||
? parseInt(String(metadata.metadata.trackNumber), 10) || null
|
||||
: null,
|
||||
discNumber: null,
|
||||
year: metadata?.metadata?.year ? parseInt(String(metadata.metadata.year), 10) || null : null,
|
||||
duration: null,
|
||||
filePath,
|
||||
fileSize,
|
||||
coverArtPath,
|
||||
addedAt: new Date().toISOString(),
|
||||
lastPlayedAt: null,
|
||||
playCount: 0,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
await insertSong(song);
|
||||
return song;
|
||||
}
|
||||
176
apps/mukke/apps/mobile/services/libraryService.ts
Normal file
176
apps/mukke/apps/mobile/services/libraryService.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { Album, Artist, Genre, Song } from '~/types';
|
||||
|
||||
import { getDatabase } from './database';
|
||||
import { deleteFile } from './fileService';
|
||||
|
||||
export async function insertSong(song: Song): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
`INSERT INTO songs (id, title, artist, album, albumArtist, genre, trackNumber, discNumber, year, duration, filePath, fileSize, coverArtPath, addedAt, lastPlayedAt, playCount, favorite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
song.id,
|
||||
song.title,
|
||||
song.artist,
|
||||
song.album,
|
||||
song.albumArtist,
|
||||
song.genre,
|
||||
song.trackNumber,
|
||||
song.discNumber,
|
||||
song.year,
|
||||
song.duration,
|
||||
song.filePath,
|
||||
song.fileSize,
|
||||
song.coverArtPath,
|
||||
song.addedAt,
|
||||
song.lastPlayedAt,
|
||||
song.playCount,
|
||||
song.favorite ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllSongs(
|
||||
orderBy: string = 'title',
|
||||
direction: 'ASC' | 'DESC' = 'ASC'
|
||||
): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const validColumns = ['title', 'artist', 'album', 'addedAt', 'playCount'];
|
||||
const col = validColumns.includes(orderBy) ? orderBy : 'title';
|
||||
const dir = direction === 'DESC' ? 'DESC' : 'ASC';
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
`SELECT * FROM songs ORDER BY ${col} ${dir}`
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getSongById(id: string): Promise<Song | null> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE id = ?',
|
||||
id
|
||||
);
|
||||
if (!row) return null;
|
||||
return { ...row, favorite: row.favorite === 1 };
|
||||
}
|
||||
|
||||
export async function deleteSong(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const song = await getSongById(id);
|
||||
if (song) {
|
||||
await deleteFile(song.filePath);
|
||||
if (song.coverArtPath) {
|
||||
await deleteFile(song.coverArtPath);
|
||||
}
|
||||
}
|
||||
await db.runAsync('DELETE FROM songs WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function toggleFavorite(id: string): Promise<boolean> {
|
||||
const db = await getDatabase();
|
||||
const song = await getSongById(id);
|
||||
if (!song) return false;
|
||||
const newFav = !song.favorite;
|
||||
await db.runAsync('UPDATE songs SET favorite = ? WHERE id = ?', newFav ? 1 : 0, id);
|
||||
return newFav;
|
||||
}
|
||||
|
||||
export async function updatePlayStats(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
'UPDATE songs SET playCount = playCount + 1, lastPlayedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSongDuration(id: string, duration: number): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('UPDATE songs SET duration = ? WHERE id = ?', duration, id);
|
||||
}
|
||||
|
||||
export async function getAlbums(): Promise<Album[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Album>(`
|
||||
SELECT
|
||||
album AS name,
|
||||
COALESCE(albumArtist, artist) AS artist,
|
||||
year,
|
||||
(SELECT coverArtPath FROM songs s2 WHERE s2.album = songs.album AND s2.coverArtPath IS NOT NULL LIMIT 1) AS coverArtPath,
|
||||
COUNT(*) AS songCount
|
||||
FROM songs
|
||||
WHERE album IS NOT NULL AND album != ''
|
||||
GROUP BY album
|
||||
ORDER BY album ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByAlbum(albumName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE album = ? ORDER BY discNumber ASC, trackNumber ASC, title ASC',
|
||||
albumName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<Artist[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Artist>(`
|
||||
SELECT
|
||||
artist AS name,
|
||||
COUNT(*) AS songCount,
|
||||
COUNT(DISTINCT album) AS albumCount
|
||||
FROM songs
|
||||
WHERE artist IS NOT NULL AND artist != ''
|
||||
GROUP BY artist
|
||||
ORDER BY artist ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByArtist(artistName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE artist = ? ORDER BY album ASC, trackNumber ASC, title ASC',
|
||||
artistName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getGenres(): Promise<Genre[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Genre>(`
|
||||
SELECT
|
||||
genre AS name,
|
||||
COUNT(*) AS songCount
|
||||
FROM songs
|
||||
WHERE genre IS NOT NULL AND genre != ''
|
||||
GROUP BY genre
|
||||
ORDER BY genre ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSongsByGenre(genreName: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE genre = ? ORDER BY artist ASC, album ASC, trackNumber ASC',
|
||||
genreName
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function searchSongs(query: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const q = `%${query}%`;
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
'SELECT * FROM songs WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? ORDER BY title ASC LIMIT 50',
|
||||
q,
|
||||
q,
|
||||
q
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getSongCount(): Promise<number> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<{ count: number }>('SELECT COUNT(*) as count FROM songs');
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
123
apps/mukke/apps/mobile/services/playlistService.ts
Normal file
123
apps/mukke/apps/mobile/services/playlistService.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { Playlist, PlaylistSong, Song } from '~/types';
|
||||
|
||||
import { getDatabase } from './database';
|
||||
|
||||
export async function createPlaylist(name: string, description?: string): Promise<Playlist> {
|
||||
const db = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const playlist: Playlist = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description: description || null,
|
||||
coverArtPath: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.runAsync(
|
||||
'INSERT INTO playlists (id, name, description, coverArtPath, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
playlist.description,
|
||||
playlist.coverArtPath,
|
||||
playlist.createdAt,
|
||||
playlist.updatedAt
|
||||
);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
export async function getAllPlaylists(): Promise<Playlist[]> {
|
||||
const db = await getDatabase();
|
||||
return db.getAllAsync<Playlist>('SELECT * FROM playlists ORDER BY updatedAt DESC');
|
||||
}
|
||||
|
||||
export async function getPlaylistById(id: string): Promise<Playlist | null> {
|
||||
const db = await getDatabase();
|
||||
return db.getFirstAsync<Playlist>('SELECT * FROM playlists WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
id: string,
|
||||
updates: { name?: string; description?: string }
|
||||
): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const sets: string[] = ['updatedAt = ?'];
|
||||
const values: (string | null)[] = [new Date().toISOString()];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
sets.push('name = ?');
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
sets.push('description = ?');
|
||||
values.push(updates.description);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
await db.runAsync(`UPDATE playlists SET ${sets.join(', ')} WHERE id = ?`, ...values);
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('DELETE FROM playlists WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function addSongToPlaylist(playlistId: string, songId: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
const maxOrder = await db.getFirstAsync<{ maxOrder: number | null }>(
|
||||
'SELECT MAX(sortOrder) as maxOrder FROM playlist_songs WHERE playlistId = ?',
|
||||
playlistId
|
||||
);
|
||||
const sortOrder = (maxOrder?.maxOrder ?? -1) + 1;
|
||||
|
||||
await db.runAsync(
|
||||
'INSERT INTO playlist_songs (id, playlistId, songId, sortOrder, addedAt) VALUES (?, ?, ?, ?, ?)',
|
||||
uuidv4(),
|
||||
playlistId,
|
||||
songId,
|
||||
sortOrder,
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
await db.runAsync(
|
||||
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
playlistId
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeSongFromPlaylist(playlistId: string, songId: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync(
|
||||
'DELETE FROM playlist_songs WHERE playlistId = ? AND songId = ?',
|
||||
playlistId,
|
||||
songId
|
||||
);
|
||||
await db.runAsync(
|
||||
'UPDATE playlists SET updatedAt = ? WHERE id = ?',
|
||||
new Date().toISOString(),
|
||||
playlistId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<Song & { favorite: number }>(
|
||||
`SELECT s.* FROM songs s
|
||||
INNER JOIN playlist_songs ps ON s.id = ps.songId
|
||||
WHERE ps.playlistId = ?
|
||||
ORDER BY ps.sortOrder ASC`,
|
||||
playlistId
|
||||
);
|
||||
return rows.map((r) => ({ ...r, favorite: r.favorite === 1 }));
|
||||
}
|
||||
|
||||
export async function getPlaylistSongCount(playlistId: string): Promise<number> {
|
||||
const db = await getDatabase();
|
||||
const row = await db.getFirstAsync<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM playlist_songs WHERE playlistId = ?',
|
||||
playlistId
|
||||
);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
60
apps/mukke/apps/mobile/services/queueService.ts
Normal file
60
apps/mukke/apps/mobile/services/queueService.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Song } from '~/types';
|
||||
|
||||
export interface QueueState {
|
||||
queue: Song[];
|
||||
originalQueue: Song[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export function createQueue(songs: Song[], startIndex: number = 0): QueueState {
|
||||
return {
|
||||
queue: [...songs],
|
||||
originalQueue: [...songs],
|
||||
currentIndex: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function shuffleQueue(state: QueueState): QueueState {
|
||||
const currentSong = state.queue[state.currentIndex];
|
||||
const remaining = state.queue.filter((_, i) => i !== state.currentIndex);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = remaining.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[remaining[i], remaining[j]] = [remaining[j], remaining[i]];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
queue: currentSong ? [currentSong, ...remaining] : remaining,
|
||||
currentIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function unshuffleQueue(state: QueueState): QueueState {
|
||||
const currentSong = state.queue[state.currentIndex];
|
||||
const newIndex = currentSong ? state.originalQueue.findIndex((s) => s.id === currentSong.id) : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
queue: [...state.originalQueue],
|
||||
currentIndex: Math.max(0, newIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextIndex(state: QueueState, repeatMode: 'off' | 'all' | 'one'): number | null {
|
||||
if (repeatMode === 'one') return state.currentIndex;
|
||||
if (state.currentIndex < state.queue.length - 1) return state.currentIndex + 1;
|
||||
if (repeatMode === 'all') return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPreviousIndex(
|
||||
state: QueueState,
|
||||
repeatMode: 'off' | 'all' | 'one'
|
||||
): number | null {
|
||||
if (repeatMode === 'one') return state.currentIndex;
|
||||
if (state.currentIndex > 0) return state.currentIndex - 1;
|
||||
if (repeatMode === 'all') return state.queue.length - 1;
|
||||
return null;
|
||||
}
|
||||
95
apps/mukke/apps/mobile/stores/libraryStore.ts
Normal file
95
apps/mukke/apps/mobile/stores/libraryStore.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { Album, Artist, Genre, LibraryTab, Song, SortDirection, SortField } from '~/types';
|
||||
import * as libraryService from '~/services/libraryService';
|
||||
|
||||
interface LibraryState {
|
||||
songs: Song[];
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
genres: Genre[];
|
||||
activeTab: LibraryTab;
|
||||
sortField: SortField;
|
||||
sortDirection: SortDirection;
|
||||
isLoading: boolean;
|
||||
songCount: number;
|
||||
|
||||
setActiveTab: (tab: LibraryTab) => void;
|
||||
setSortField: (field: SortField) => void;
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
loadSongs: () => Promise<void>;
|
||||
loadAlbums: () => Promise<void>;
|
||||
loadArtists: () => Promise<void>;
|
||||
loadGenres: () => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
toggleFavorite: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
songs: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
genres: [],
|
||||
activeTab: 'songs',
|
||||
sortField: 'title',
|
||||
sortDirection: 'asc',
|
||||
isLoading: false,
|
||||
songCount: 0,
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
setSortField: (field) => {
|
||||
set({ sortField: field });
|
||||
get().loadSongs();
|
||||
},
|
||||
|
||||
setSortDirection: (dir) => {
|
||||
set({ sortDirection: dir });
|
||||
get().loadSongs();
|
||||
},
|
||||
|
||||
loadSongs: async () => {
|
||||
const { sortField, sortDirection } = get();
|
||||
const songs = await libraryService.getAllSongs(
|
||||
sortField,
|
||||
sortDirection.toUpperCase() as 'ASC' | 'DESC'
|
||||
);
|
||||
set({ songs, songCount: songs.length });
|
||||
},
|
||||
|
||||
loadAlbums: async () => {
|
||||
const albums = await libraryService.getAlbums();
|
||||
set({ albums });
|
||||
},
|
||||
|
||||
loadArtists: async () => {
|
||||
const artists = await libraryService.getArtists();
|
||||
set({ artists });
|
||||
},
|
||||
|
||||
loadGenres: async () => {
|
||||
const genres = await libraryService.getGenres();
|
||||
set({ genres });
|
||||
},
|
||||
|
||||
loadAll: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await Promise.all([
|
||||
get().loadSongs(),
|
||||
get().loadAlbums(),
|
||||
get().loadArtists(),
|
||||
get().loadGenres(),
|
||||
]);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: async (id) => {
|
||||
const newFav = await libraryService.toggleFavorite(id);
|
||||
set((state) => ({
|
||||
songs: state.songs.map((s) => (s.id === id ? { ...s, favorite: newFav } : s)),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
134
apps/mukke/apps/mobile/stores/playerStore.ts
Normal file
134
apps/mukke/apps/mobile/stores/playerStore.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { RepeatMode, ShuffleMode, Song } from '~/types';
|
||||
import {
|
||||
createQueue,
|
||||
getNextIndex,
|
||||
getPreviousIndex,
|
||||
shuffleQueue,
|
||||
unshuffleQueue,
|
||||
type QueueState,
|
||||
} from '~/services/queueService';
|
||||
|
||||
interface PlayerState {
|
||||
currentSong: Song | null;
|
||||
isPlaying: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleMode: ShuffleMode;
|
||||
queueState: QueueState;
|
||||
|
||||
playSong: (song: Song, queue?: Song[], startIndex?: number) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setPosition: (position: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
toggleRepeat: () => void;
|
||||
toggleShuffle: () => void;
|
||||
nextSong: () => Song | null;
|
||||
previousSong: () => Song | null;
|
||||
getQueue: () => Song[];
|
||||
clearQueue: () => void;
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentSong: null,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
repeatMode: 'off',
|
||||
shuffleMode: 'off',
|
||||
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
|
||||
|
||||
playSong: (song, queue, startIndex) => {
|
||||
let queueState: QueueState;
|
||||
if (queue && queue.length > 0) {
|
||||
const idx = startIndex ?? queue.findIndex((s) => s.id === song.id);
|
||||
queueState = createQueue(queue, Math.max(0, idx));
|
||||
if (get().shuffleMode === 'on') {
|
||||
queueState = shuffleQueue(queueState);
|
||||
}
|
||||
} else {
|
||||
queueState = createQueue([song], 0);
|
||||
}
|
||||
set({ currentSong: song, isPlaying: true, position: 0, duration: 0, queueState });
|
||||
},
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPosition: (position) => set({ position }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
|
||||
toggleRepeat: () => {
|
||||
const modes: RepeatMode[] = ['off', 'all', 'one'];
|
||||
const current = modes.indexOf(get().repeatMode);
|
||||
set({ repeatMode: modes[(current + 1) % modes.length] });
|
||||
},
|
||||
|
||||
toggleShuffle: () => {
|
||||
const { shuffleMode, queueState } = get();
|
||||
if (shuffleMode === 'off') {
|
||||
set({
|
||||
shuffleMode: 'on',
|
||||
queueState: shuffleQueue(queueState),
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
shuffleMode: 'off',
|
||||
queueState: unshuffleQueue(queueState),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
nextSong: () => {
|
||||
const { queueState, repeatMode } = get();
|
||||
const nextIdx = getNextIndex(queueState, repeatMode);
|
||||
if (nextIdx === null) {
|
||||
set({ isPlaying: false });
|
||||
return null;
|
||||
}
|
||||
const song = queueState.queue[nextIdx];
|
||||
set({
|
||||
currentSong: song,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
isPlaying: true,
|
||||
queueState: { ...queueState, currentIndex: nextIdx },
|
||||
});
|
||||
return song;
|
||||
},
|
||||
|
||||
previousSong: () => {
|
||||
const { queueState, repeatMode, position } = get();
|
||||
// If more than 3 seconds in, restart current song
|
||||
if (position > 3) {
|
||||
set({ position: 0 });
|
||||
return get().currentSong;
|
||||
}
|
||||
const prevIdx = getPreviousIndex(queueState, repeatMode);
|
||||
if (prevIdx === null) {
|
||||
set({ position: 0 });
|
||||
return get().currentSong;
|
||||
}
|
||||
const song = queueState.queue[prevIdx];
|
||||
set({
|
||||
currentSong: song,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
isPlaying: true,
|
||||
queueState: { ...queueState, currentIndex: prevIdx },
|
||||
});
|
||||
return song;
|
||||
},
|
||||
|
||||
getQueue: () => get().queueState.queue,
|
||||
|
||||
clearQueue: () => {
|
||||
set({
|
||||
currentSong: null,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
queueState: { queue: [], originalQueue: [], currentIndex: 0 },
|
||||
});
|
||||
},
|
||||
}));
|
||||
45
apps/mukke/apps/mobile/stores/playlistStore.ts
Normal file
45
apps/mukke/apps/mobile/stores/playlistStore.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import type { Playlist } from '~/types';
|
||||
import * as playlistService from '~/services/playlistService';
|
||||
|
||||
interface PlaylistState {
|
||||
playlists: Playlist[];
|
||||
isLoading: boolean;
|
||||
|
||||
loadPlaylists: () => Promise<void>;
|
||||
createPlaylist: (name: string, description?: string) => Promise<Playlist>;
|
||||
deletePlaylist: (id: string) => Promise<void>;
|
||||
updatePlaylist: (id: string, updates: { name?: string; description?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlaylistStore = create<PlaylistState>((set, get) => ({
|
||||
playlists: [],
|
||||
isLoading: false,
|
||||
|
||||
loadPlaylists: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const playlists = await playlistService.getAllPlaylists();
|
||||
set({ playlists });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createPlaylist: async (name, description) => {
|
||||
const playlist = await playlistService.createPlaylist(name, description);
|
||||
set((state) => ({ playlists: [playlist, ...state.playlists] }));
|
||||
return playlist;
|
||||
},
|
||||
|
||||
deletePlaylist: async (id) => {
|
||||
await playlistService.deletePlaylist(id);
|
||||
set((state) => ({ playlists: state.playlists.filter((p) => p.id !== id) }));
|
||||
},
|
||||
|
||||
updatePlaylist: async (id, updates) => {
|
||||
await playlistService.updatePlaylist(id, updates);
|
||||
await get().loadPlaylists();
|
||||
},
|
||||
}));
|
||||
24
apps/mukke/apps/mobile/tailwind.config.js
Normal file
24
apps/mukke/apps/mobile/tailwind.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#FF6B35',
|
||||
'primary-dark': '#E55A2B',
|
||||
accent: '#FF8F65',
|
||||
background: {
|
||||
light: '#FFFFFF',
|
||||
dark: '#121212',
|
||||
},
|
||||
text: {
|
||||
light: '#000000',
|
||||
dark: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
12
apps/mukke/apps/mobile/tsconfig.json
Normal file
12
apps/mukke/apps/mobile/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
14
apps/mukke/apps/mobile/types/index.ts
Normal file
14
apps/mukke/apps/mobile/types/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export type {
|
||||
Song,
|
||||
Album,
|
||||
Artist,
|
||||
Genre,
|
||||
Playlist,
|
||||
PlaylistSong,
|
||||
RepeatMode,
|
||||
ShuffleMode,
|
||||
LibraryTab,
|
||||
SortField,
|
||||
SortDirection,
|
||||
SortOption,
|
||||
} from '@mukke/types';
|
||||
190
apps/mukke/apps/mobile/utils/themeContext.tsx
Normal file
190
apps/mukke/apps/mobile/utils/themeContext.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
export type ThemeVariant = 'classic' | 'ocean' | 'sunset';
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
border: string;
|
||||
card: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
const THEME_VARIANTS = {
|
||||
classic: {
|
||||
primary: '#FF6B35',
|
||||
primaryDark: '#E55A2B',
|
||||
accent: '#FF8F65',
|
||||
},
|
||||
ocean: {
|
||||
primary: '#2196F3',
|
||||
primaryDark: '#1976D2',
|
||||
accent: '#42A5F5',
|
||||
},
|
||||
sunset: {
|
||||
primary: '#FF6B6B',
|
||||
primaryDark: '#FF5252',
|
||||
accent: '#FF8A80',
|
||||
},
|
||||
};
|
||||
|
||||
const getThemeColors = (variant: ThemeVariant, isDark: boolean): ThemeColors => {
|
||||
const variantColors = THEME_VARIANTS[variant];
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
primary: variantColors.primary,
|
||||
primaryDark: variantColors.primaryDark,
|
||||
accent: variantColors.accent,
|
||||
background: '#121212',
|
||||
backgroundSecondary: '#1E1E1E',
|
||||
backgroundTertiary: '#2D2D2D',
|
||||
text: '#FFFFFF',
|
||||
textSecondary: '#AAAAAA',
|
||||
textTertiary: '#888888',
|
||||
border: '#333333',
|
||||
card: '#1E1E1E',
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#FF6B6B',
|
||||
shadow: '#000000',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
primary: variantColors.primary,
|
||||
primaryDark: variantColors.primaryDark,
|
||||
accent: variantColors.accent,
|
||||
background: '#F5F5F5',
|
||||
backgroundSecondary: '#FFFFFF',
|
||||
backgroundTertiary: '#F0F0F0',
|
||||
text: '#000000',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
border: '#E0E0E0',
|
||||
card: '#FFFFFF',
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#F44336',
|
||||
shadow: '#000000',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
isDarkMode: boolean;
|
||||
themeVariant: ThemeVariant;
|
||||
colors: ThemeColors;
|
||||
toggleTheme: () => void;
|
||||
setDarkMode: (isDark: boolean) => void;
|
||||
setThemeVariant: (variant: ThemeVariant) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
isDarkMode: false,
|
||||
themeVariant: 'classic',
|
||||
colors: getThemeColors('classic', false),
|
||||
toggleTheme: () => {},
|
||||
setDarkMode: () => {},
|
||||
setThemeVariant: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
const THEME_PREFERENCE_KEY = '@mukke_theme_preference';
|
||||
const THEME_VARIANT_KEY = '@mukke_theme_variant';
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode | ((themeProps: ThemeContextType) => React.ReactNode);
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [themeVariant, setThemeVariantState] = useState<ThemeVariant>('classic');
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadThemePreferences = async () => {
|
||||
try {
|
||||
const [savedPreference, savedVariant] = await Promise.all([
|
||||
AsyncStorage.getItem(THEME_PREFERENCE_KEY),
|
||||
AsyncStorage.getItem(THEME_VARIANT_KEY),
|
||||
]);
|
||||
|
||||
if (savedPreference !== null) {
|
||||
setIsDarkMode(savedPreference === 'dark');
|
||||
} else {
|
||||
setIsDarkMode(systemColorScheme === 'dark');
|
||||
}
|
||||
|
||||
if (savedVariant !== null && ['classic', 'ocean', 'sunset'].includes(savedVariant)) {
|
||||
setThemeVariantState(savedVariant as ThemeVariant);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme preferences', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadThemePreferences();
|
||||
}, [systemColorScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
AsyncStorage.setItem(THEME_PREFERENCE_KEY, isDarkMode ? 'dark' : 'light').catch((error) =>
|
||||
console.error('Failed to save theme preference', error)
|
||||
);
|
||||
}
|
||||
}, [isDarkMode, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
AsyncStorage.setItem(THEME_VARIANT_KEY, themeVariant).catch((error) =>
|
||||
console.error('Failed to save theme variant', error)
|
||||
);
|
||||
}
|
||||
}, [themeVariant, isLoaded]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDarkMode((prev) => !prev);
|
||||
};
|
||||
|
||||
const setDarkMode = (isDark: boolean) => {
|
||||
setIsDarkMode(isDark);
|
||||
};
|
||||
|
||||
const setThemeVariant = (variant: ThemeVariant) => {
|
||||
setThemeVariantState(variant);
|
||||
};
|
||||
|
||||
const colors = getThemeColors(themeVariant, isDarkMode);
|
||||
|
||||
const themeContextValue = {
|
||||
isDarkMode,
|
||||
themeVariant,
|
||||
colors,
|
||||
toggleTheme,
|
||||
setDarkMode,
|
||||
setThemeVariant,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={themeContextValue}>
|
||||
{typeof children === 'function' ? children(themeContextValue) : children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
9
apps/mukke/package.json
Normal file
9
apps/mukke/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "mukke",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Mukke - Offline-first iOS Music Player",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@mukke/* --parallel dev"
|
||||
}
|
||||
}
|
||||
7
apps/mukke/packages/mukke-types/package.json
Normal file
7
apps/mukke/packages/mukke-types/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@mukke/types",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": true
|
||||
}
|
||||
69
apps/mukke/packages/mukke-types/src/index.ts
Normal file
69
apps/mukke/packages/mukke-types/src/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumArtist: string | null;
|
||||
genre: string | null;
|
||||
trackNumber: number | null;
|
||||
discNumber: number | null;
|
||||
year: number | null;
|
||||
duration: number | null;
|
||||
filePath: string;
|
||||
fileSize: number | null;
|
||||
coverArtPath: string | null;
|
||||
addedAt: string;
|
||||
lastPlayedAt: string | null;
|
||||
playCount: number;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
name: string;
|
||||
artist: string | null;
|
||||
year: number | null;
|
||||
coverArtPath: string | null;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
name: string;
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
name: string;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
coverArtPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistSong {
|
||||
id: string;
|
||||
playlistId: string;
|
||||
songId: string;
|
||||
sortOrder: number;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export type RepeatMode = 'off' | 'all' | 'one';
|
||||
export type ShuffleMode = 'off' | 'on';
|
||||
|
||||
export type LibraryTab = 'songs' | 'albums' | 'artists' | 'genres';
|
||||
|
||||
export type SortField = 'title' | 'artist' | 'album' | 'addedAt' | 'playCount';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortOption {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
label: string;
|
||||
}
|
||||
12
apps/mukke/packages/mukke-types/tsconfig.json
Normal file
12
apps/mukke/packages/mukke-types/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue