mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
fix: better music modal design and favorite song
This commit is contained in:
@@ -23,11 +23,13 @@ import DraggableFlatList, {
|
|||||||
} from "react-native-draggable-flatlist";
|
} from "react-native-draggable-flatlist";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import type { VolumeResult } from "react-native-volume-manager";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
type RepeatMode,
|
type RepeatMode,
|
||||||
@@ -36,6 +38,11 @@ import {
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { formatDuration } from "@/utils/time";
|
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) => {
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
if (!bytes) return null;
|
if (!bytes) return null;
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
@@ -87,6 +94,10 @@ export default function NowPlayingScreen() {
|
|||||||
stop,
|
stop,
|
||||||
} = useMusicPlayer();
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(
|
||||||
|
currentTrack ?? ({ Id: "" } as BaseItemDto),
|
||||||
|
);
|
||||||
|
|
||||||
const sliderProgress = useSharedValue(0);
|
const sliderProgress = useSharedValue(0);
|
||||||
const sliderMin = useSharedValue(0);
|
const sliderMin = useSharedValue(0);
|
||||||
const sliderMax = useSharedValue(1);
|
const sliderMax = useSharedValue(1);
|
||||||
@@ -113,11 +124,17 @@ export default function NowPlayingScreen() {
|
|||||||
return formatDuration(progressTicks);
|
return formatDuration(progressTicks);
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
const durationText = useMemo(() => {
|
const _durationText = useMemo(() => {
|
||||||
const durationTicks = duration * 10000000;
|
const durationTicks = duration * 10000000;
|
||||||
return formatDuration(durationTicks);
|
return formatDuration(durationTicks);
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
|
const remainingText = useMemo(() => {
|
||||||
|
const remaining = Math.max(0, duration - progress);
|
||||||
|
const remainingTicks = remaining * 10000000;
|
||||||
|
return `-${formatDuration(remainingTicks)}`;
|
||||||
|
}, [duration, progress]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
seek(value);
|
seek(value);
|
||||||
@@ -232,13 +249,8 @@ export default function NowPlayingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
{/* Empty placeholder to balance header layout */}
|
||||||
onPress={handleOptionsPress}
|
<View className='p-2' style={{ width: 44 }} />
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{viewMode === "player" ? (
|
{viewMode === "player" ? (
|
||||||
@@ -250,7 +262,7 @@ export default function NowPlayingScreen() {
|
|||||||
sliderMin={sliderMin}
|
sliderMin={sliderMin}
|
||||||
sliderMax={sliderMax}
|
sliderMax={sliderMax}
|
||||||
progressText={progressText}
|
progressText={progressText}
|
||||||
durationText={durationText}
|
remainingText={remainingText}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
repeatMode={repeatMode}
|
repeatMode={repeatMode}
|
||||||
@@ -264,10 +276,11 @@ export default function NowPlayingScreen() {
|
|||||||
onCycleRepeat={cycleRepeatMode}
|
onCycleRepeat={cycleRepeatMode}
|
||||||
onToggleShuffle={toggleShuffle}
|
onToggleShuffle={toggleShuffle}
|
||||||
getRepeatIcon={getRepeatIcon}
|
getRepeatIcon={getRepeatIcon}
|
||||||
queue={queue}
|
|
||||||
queueIndex={queueIndex}
|
|
||||||
mediaSource={mediaSource}
|
mediaSource={mediaSource}
|
||||||
isTranscoding={isTranscoding}
|
isTranscoding={isTranscoding}
|
||||||
|
isFavorite={isFavorite}
|
||||||
|
onToggleFavorite={toggleFavorite}
|
||||||
|
onOptionsPress={handleOptionsPress}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<QueueView
|
<QueueView
|
||||||
@@ -310,7 +323,7 @@ interface PlayerViewProps {
|
|||||||
sliderMin: any;
|
sliderMin: any;
|
||||||
sliderMax: any;
|
sliderMax: any;
|
||||||
progressText: string;
|
progressText: string;
|
||||||
durationText: string;
|
remainingText: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
repeatMode: RepeatMode;
|
repeatMode: RepeatMode;
|
||||||
@@ -324,10 +337,11 @@ interface PlayerViewProps {
|
|||||||
onCycleRepeat: () => void;
|
onCycleRepeat: () => void;
|
||||||
onToggleShuffle: () => void;
|
onToggleShuffle: () => void;
|
||||||
getRepeatIcon: () => string;
|
getRepeatIcon: () => string;
|
||||||
queue: BaseItemDto[];
|
|
||||||
queueIndex: number;
|
|
||||||
mediaSource: MediaSourceInfo | null;
|
mediaSource: MediaSourceInfo | null;
|
||||||
isTranscoding: boolean;
|
isTranscoding: boolean;
|
||||||
|
isFavorite: boolean | undefined;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
onOptionsPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||||
@@ -337,7 +351,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
sliderMin,
|
sliderMin,
|
||||||
sliderMax,
|
sliderMax,
|
||||||
progressText,
|
progressText,
|
||||||
durationText,
|
remainingText,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isLoading,
|
isLoading,
|
||||||
repeatMode,
|
repeatMode,
|
||||||
@@ -351,15 +365,41 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
onCycleRepeat,
|
onCycleRepeat,
|
||||||
onToggleShuffle,
|
onToggleShuffle,
|
||||||
getRepeatIcon,
|
getRepeatIcon,
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isTranscoding,
|
isTranscoding,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
onOptionsPress,
|
||||||
}) => {
|
}) => {
|
||||||
const audioStream = useMemo(() => {
|
const audioStream = useMemo(() => {
|
||||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||||
}, [mediaSource]);
|
}, [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 fileSize = formatFileSize(mediaSource?.Size);
|
||||||
const codec = audioStream?.Codec?.toUpperCase();
|
const codec = audioStream?.Codec?.toUpperCase();
|
||||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||||
@@ -400,19 +440,33 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Track info */}
|
{/* Track info with actions */}
|
||||||
<View className='mb-6'>
|
<View className='mb-6'>
|
||||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
<View className='flex-row items-start justify-between'>
|
||||||
{currentTrack.Name}
|
<View className='flex-1 mr-4'>
|
||||||
</Text>
|
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
{currentTrack.Name}
|
||||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
</Text>
|
||||||
</Text>
|
<Text numberOfLines={1} className='text-neutral-400 text-lg mt-1'>
|
||||||
{currentTrack.Album && (
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
</Text>
|
||||||
{currentTrack.Album}
|
</View>
|
||||||
</Text>
|
<TouchableOpacity
|
||||||
)}
|
onPress={onToggleFavorite}
|
||||||
|
className='p-2'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isFavorite ? "#ec4899" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Audio Stats */}
|
{/* Audio Stats */}
|
||||||
{hasAudioStats && (
|
{hasAudioStats && (
|
||||||
@@ -442,28 +496,36 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<Slider
|
<Slider
|
||||||
theme={{
|
theme={{
|
||||||
maximumTrackTintColor: "#333",
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
minimumTrackTintColor: "#9334E9",
|
minimumTrackTintColor: "#fff",
|
||||||
bubbleBackgroundColor: "#9334E9",
|
bubbleBackgroundColor: "#fff",
|
||||||
bubbleTextColor: "#fff",
|
bubbleTextColor: "#666",
|
||||||
}}
|
}}
|
||||||
progress={sliderProgress}
|
progress={sliderProgress}
|
||||||
minimumValue={sliderMin}
|
minimumValue={sliderMin}
|
||||||
maximumValue={sliderMax}
|
maximumValue={sliderMax}
|
||||||
onSlidingComplete={onSliderComplete}
|
onSlidingComplete={onSliderComplete}
|
||||||
thumbWidth={16}
|
renderThumb={() => null}
|
||||||
sliderHeight={6}
|
sliderHeight={8}
|
||||||
containerStyle={{ borderRadius: 10 }}
|
containerStyle={{ borderRadius: 100 }}
|
||||||
renderBubble={() => null}
|
renderBubble={() => null}
|
||||||
/>
|
/>
|
||||||
<View className='flex flex-row justify-between px-1 mt-2'>
|
<View className='flex flex-row justify-between mt-2'>
|
||||||
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||||
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Main Controls */}
|
{/* Main Controls with Shuffle & Repeat */}
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
<View className='flex flex-row items-center justify-center mb-6'>
|
||||||
|
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
|
||||||
|
<Ionicons
|
||||||
|
name='shuffle'
|
||||||
|
size={24}
|
||||||
|
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPrevious}
|
onPress={onPrevious}
|
||||||
disabled={!canGoPrevious || isLoading}
|
disabled={!canGoPrevious || isLoading}
|
||||||
@@ -476,7 +538,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onTogglePlayPause}
|
onPress={onTogglePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='mx-8 bg-white rounded-full p-4'
|
className='mx-4 bg-white rounded-full p-4'
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size={36} color='#121212' />
|
<ActivityIndicator size={36} color='#121212' />
|
||||||
@@ -498,38 +560,42 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Shuffle & Repeat Controls */}
|
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
|
||||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
|
||||||
<Ionicons
|
|
||||||
name='shuffle'
|
|
||||||
size={24}
|
|
||||||
color={shuffleEnabled ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={getRepeatIcon() as any}
|
name={getRepeatIcon() as any}
|
||||||
size={24}
|
size={24}
|
||||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||||
/>
|
/>
|
||||||
{repeatMode === "one" && (
|
{repeatMode === "one" && (
|
||||||
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
<View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Queue info */}
|
{/* Volume Slider */}
|
||||||
{queue.length > 1 && (
|
{!isTv && VolumeManager && (
|
||||||
<View className='items-center mb-4'>
|
<View className='flex-row items-center mb-4'>
|
||||||
<Text className='text-neutral-500 text-sm'>
|
<Ionicons name='volume-low' size={20} color='#666' />
|
||||||
{queueIndex + 1} of {queue.length}
|
<View className='flex-1 mx-3'>
|
||||||
</Text>
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
}}
|
||||||
|
progress={volumeProgress}
|
||||||
|
minimumValue={volumeMin}
|
||||||
|
maximumValue={volumeMax}
|
||||||
|
onSlidingComplete={handleVolumeChange}
|
||||||
|
renderThumb={() => null}
|
||||||
|
sliderHeight={8}
|
||||||
|
containerStyle={{ borderRadius: 100 }}
|
||||||
|
renderBubble={() => null}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Ionicons name='volume-high' size={20} color='#666' />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,22 +1,63 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useAtom } from "jotai";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Shared atom to store favorite status across all components
|
||||||
|
// Maps itemId -> isFavorite
|
||||||
|
const favoritesAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
export const useFavorite = (item: BaseItemDto) => {
|
export const useFavorite = (item: BaseItemDto) => {
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [isFavorite, setIsFavorite] = useState<boolean | undefined>(
|
const [favorites, setFavorites] = useAtom(favoritesAtom);
|
||||||
item.UserData?.IsFavorite,
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
setIsFavorite(item.UserData?.IsFavorite);
|
itemRef.current = item;
|
||||||
}, [item.UserData?.IsFavorite]);
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiRef.current = api;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userRef.current = user;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const itemQueryKeyPrefix = useMemo(
|
const itemQueryKeyPrefix = useMemo(
|
||||||
() => ["item", item.Id] as const,
|
() => ["item", item.Id] as const,
|
||||||
@@ -42,18 +83,23 @@ export const useFavorite = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const favoriteMutation = useMutation({
|
const favoriteMutation = useMutation({
|
||||||
mutationFn: async (nextIsFavorite: boolean) => {
|
mutationFn: async (nextIsFavorite: boolean) => {
|
||||||
if (!api || !user || !item.Id) return;
|
const currentApi = apiRef.current;
|
||||||
if (nextIsFavorite) {
|
const currentUser = userRef.current;
|
||||||
await getUserLibraryApi(api).markFavoriteItem({
|
const currentItem = itemRef.current;
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id,
|
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
|
||||||
userId: user.Id,
|
// Use the same endpoint format as the web client:
|
||||||
itemId: item.Id,
|
// 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) => {
|
onMutate: async (nextIsFavorite: boolean) => {
|
||||||
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||||
|
|||||||
Reference in New Issue
Block a user