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