From 3453fd22b807d6faed7d43c62ce33ca4bdb3afd1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 6 Jan 2026 17:55:37 +0100 Subject: [PATCH] fix: better music modal design and favorite song --- app/(auth)/now-playing.tsx | 186 +++++++++++++++++++++++++------------ hooks/useFavorite.ts | 86 +++++++++++++---- 2 files changed, 192 insertions(+), 80 deletions(-) diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index f2bef428..a81c86e7 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -23,11 +23,13 @@ import DraggableFlatList, { } from "react-native-draggable-flatlist"; import { useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { VolumeResult } from "react-native-volume-manager"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { useFavorite } from "@/hooks/useFavorite"; import { apiAtom } from "@/providers/JellyfinProvider"; import { type RepeatMode, @@ -36,6 +38,11 @@ import { import { formatBitrate } from "@/utils/bitrate"; import { formatDuration } from "@/utils/time"; +// Conditionally require VolumeManager (not available on TV) +const VolumeManager = Platform.isTV + ? null + : require("react-native-volume-manager"); + const formatFileSize = (bytes?: number | null) => { if (!bytes) return null; const sizes = ["B", "KB", "MB", "GB"]; @@ -87,6 +94,10 @@ export default function NowPlayingScreen() { stop, } = useMusicPlayer(); + const { isFavorite, toggleFavorite } = useFavorite( + currentTrack ?? ({ Id: "" } as BaseItemDto), + ); + const sliderProgress = useSharedValue(0); const sliderMin = useSharedValue(0); const sliderMax = useSharedValue(1); @@ -113,11 +124,17 @@ export default function NowPlayingScreen() { return formatDuration(progressTicks); }, [progress]); - const durationText = useMemo(() => { + const _durationText = useMemo(() => { const durationTicks = duration * 10000000; return formatDuration(durationTicks); }, [duration]); + const remainingText = useMemo(() => { + const remaining = Math.max(0, duration - progress); + const remainingTicks = remaining * 10000000; + return `-${formatDuration(remainingTicks)}`; + }, [duration, progress]); + const handleSliderComplete = useCallback( (value: number) => { seek(value); @@ -232,13 +249,8 @@ export default function NowPlayingScreen() { - - - + {/* Empty placeholder to balance header layout */} + {viewMode === "player" ? ( @@ -250,7 +262,7 @@ export default function NowPlayingScreen() { sliderMin={sliderMin} sliderMax={sliderMax} progressText={progressText} - durationText={durationText} + remainingText={remainingText} isPlaying={isPlaying} isLoading={isLoading} repeatMode={repeatMode} @@ -264,10 +276,11 @@ export default function NowPlayingScreen() { onCycleRepeat={cycleRepeatMode} onToggleShuffle={toggleShuffle} getRepeatIcon={getRepeatIcon} - queue={queue} - queueIndex={queueIndex} mediaSource={mediaSource} isTranscoding={isTranscoding} + isFavorite={isFavorite} + onToggleFavorite={toggleFavorite} + onOptionsPress={handleOptionsPress} /> ) : ( void; onToggleShuffle: () => void; getRepeatIcon: () => string; - queue: BaseItemDto[]; - queueIndex: number; mediaSource: MediaSourceInfo | null; isTranscoding: boolean; + isFavorite: boolean | undefined; + onToggleFavorite: () => void; + onOptionsPress: () => void; } const PlayerView: React.FC = ({ @@ -337,7 +351,7 @@ const PlayerView: React.FC = ({ sliderMin, sliderMax, progressText, - durationText, + remainingText, isPlaying, isLoading, repeatMode, @@ -351,15 +365,41 @@ const PlayerView: React.FC = ({ onCycleRepeat, onToggleShuffle, getRepeatIcon, - queue, - queueIndex, mediaSource, isTranscoding, + isFavorite, + onToggleFavorite, + onOptionsPress, }) => { const audioStream = useMemo(() => { return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio"); }, [mediaSource]); + // Volume slider state + const volumeProgress = useSharedValue(0); + const volumeMin = useSharedValue(0); + const volumeMax = useSharedValue(1); + const isTv = Platform.isTV; + + useEffect(() => { + if (isTv || !VolumeManager) return; + // Get initial volume + VolumeManager.getVolume().then(({ volume }: { volume: number }) => { + volumeProgress.value = volume; + }); + // Listen to volume changes + const listener = VolumeManager.addVolumeListener((result: VolumeResult) => { + volumeProgress.value = result.volume; + }); + return () => listener.remove(); + }, [isTv, volumeProgress]); + + const handleVolumeChange = useCallback((value: number) => { + if (VolumeManager) { + VolumeManager.setVolume(value); + } + }, []); + const fileSize = formatFileSize(mediaSource?.Size); const codec = audioStream?.Codec?.toUpperCase(); const bitrate = formatBitrate(audioStream?.BitRate); @@ -400,19 +440,33 @@ const PlayerView: React.FC = ({ )} - {/* Track info */} + {/* Track info with actions */} - - {currentTrack.Name} - - - {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} - - {currentTrack.Album && ( - - {currentTrack.Album} - - )} + + + + {currentTrack.Name} + + + {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} + + + + + + + + + {/* Audio Stats */} {hasAudioStats && ( @@ -442,28 +496,36 @@ const PlayerView: React.FC = ({ null} + sliderHeight={8} + containerStyle={{ borderRadius: 100 }} renderBubble={() => null} /> - + {progressText} - {durationText} + {remainingText} - {/* Main Controls */} - + {/* Main Controls with Shuffle & Repeat */} + + + + + = ({ {isLoading ? ( @@ -498,38 +560,42 @@ const PlayerView: React.FC = ({ > - - {/* Shuffle & Repeat Controls */} - - - - - - + {repeatMode === "one" && ( - + 1 )} - {/* Queue info */} - {queue.length > 1 && ( - - - {queueIndex + 1} of {queue.length} - + {/* Volume Slider */} + {!isTv && VolumeManager && ( + + + + null} + sliderHeight={8} + containerStyle={{ borderRadius: 100 }} + renderBubble={() => null} + /> + + )} diff --git a/hooks/useFavorite.ts b/hooks/useFavorite.ts index a07afe03..77af77ee 100644 --- a/hooks/useFavorite.ts +++ b/hooks/useFavorite.ts @@ -1,22 +1,63 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; -import { useMutation } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { atom, useAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +// Shared atom to store favorite status across all components +// Maps itemId -> isFavorite +const favoritesAtom = atom>({}); + export const useFavorite = (item: BaseItemDto) => { - const queryClient = useNetworkAwareQueryClient(); + const queryClient = useQueryClient(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [isFavorite, setIsFavorite] = useState( - item.UserData?.IsFavorite, + const [favorites, setFavorites] = useAtom(favoritesAtom); + + const itemId = item.Id ?? ""; + + // Get current favorite status from shared state, falling back to item data + const isFavorite = itemId + ? (favorites[itemId] ?? item.UserData?.IsFavorite) + : item.UserData?.IsFavorite; + + // Update shared state when item data changes + useEffect(() => { + if (itemId && item.UserData?.IsFavorite !== undefined) { + setFavorites((prev) => ({ + ...prev, + [itemId]: item.UserData!.IsFavorite!, + })); + } + }, [itemId, item.UserData?.IsFavorite, setFavorites]); + + // Helper to update favorite status in shared state + const setIsFavorite = useCallback( + (value: boolean | undefined) => { + if (itemId && value !== undefined) { + setFavorites((prev) => ({ ...prev, [itemId]: value })); + } + }, + [itemId, setFavorites], ); + // Use refs to avoid stale closure issues in mutationFn + const itemRef = useRef(item); + const apiRef = useRef(api); + const userRef = useRef(user); + + // Keep refs updated useEffect(() => { - setIsFavorite(item.UserData?.IsFavorite); - }, [item.UserData?.IsFavorite]); + itemRef.current = item; + }, [item]); + + useEffect(() => { + apiRef.current = api; + }, [api]); + + useEffect(() => { + userRef.current = user; + }, [user]); const itemQueryKeyPrefix = useMemo( () => ["item", item.Id] as const, @@ -42,18 +83,23 @@ export const useFavorite = (item: BaseItemDto) => { const favoriteMutation = useMutation({ mutationFn: async (nextIsFavorite: boolean) => { - if (!api || !user || !item.Id) return; - if (nextIsFavorite) { - await getUserLibraryApi(api).markFavoriteItem({ - userId: user.Id, - itemId: item.Id, - }); + const currentApi = apiRef.current; + const currentUser = userRef.current; + const currentItem = itemRef.current; + + if (!currentApi || !currentUser?.Id || !currentItem?.Id) { return; } - await getUserLibraryApi(api).unmarkFavoriteItem({ - userId: user.Id, - itemId: item.Id, - }); + + // Use the same endpoint format as the web client: + // POST /Users/{userId}/FavoriteItems/{itemId} - add favorite + // DELETE /Users/{userId}/FavoriteItems/{itemId} - remove favorite + const path = `/Users/${currentUser.Id}/FavoriteItems/${currentItem.Id}`; + + const response = nextIsFavorite + ? await currentApi.post(path, {}, {}) + : await currentApi.delete(path, {}); + return response.data; }, onMutate: async (nextIsFavorite: boolean) => { await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });