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";
|
||||
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() {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleOptionsPress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
{/* Empty placeholder to balance header layout */}
|
||||
<View className='p-2' style={{ width: 44 }} />
|
||||
</View>
|
||||
|
||||
{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}
|
||||
/>
|
||||
) : (
|
||||
<QueueView
|
||||
@@ -310,7 +323,7 @@ interface PlayerViewProps {
|
||||
sliderMin: any;
|
||||
sliderMax: any;
|
||||
progressText: string;
|
||||
durationText: string;
|
||||
remainingText: string;
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
@@ -324,10 +337,11 @@ interface PlayerViewProps {
|
||||
onCycleRepeat: () => 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<PlayerViewProps> = ({
|
||||
@@ -337,7 +351,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
progressText,
|
||||
durationText,
|
||||
remainingText,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
repeatMode,
|
||||
@@ -351,15 +365,41 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
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<PlayerViewProps> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
{/* Track info with actions */}
|
||||
<View className='mb-6'>
|
||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
{currentTrack.Album && (
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
||||
{currentTrack.Album}
|
||||
</Text>
|
||||
)}
|
||||
<View className='flex-row items-start justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-lg mt-1'>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
<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 */}
|
||||
{hasAudioStats && (
|
||||
@@ -442,28 +496,36 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
<View className='mb-4'>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: "#9334E9",
|
||||
bubbleBackgroundColor: "#9334E9",
|
||||
bubbleTextColor: "#fff",
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#666",
|
||||
}}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
onSlidingComplete={onSliderComplete}
|
||||
thumbWidth={16}
|
||||
sliderHeight={6}
|
||||
containerStyle={{ borderRadius: 10 }}
|
||||
renderThumb={() => null}
|
||||
sliderHeight={8}
|
||||
containerStyle={{ borderRadius: 100 }}
|
||||
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'>{durationText}</Text>
|
||||
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Main Controls */}
|
||||
<View className='flex flex-row items-center justify-center mb-2'>
|
||||
{/* Main Controls with Shuffle & Repeat */}
|
||||
<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
|
||||
onPress={onPrevious}
|
||||
disabled={!canGoPrevious || isLoading}
|
||||
@@ -476,7 +538,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
<TouchableOpacity
|
||||
onPress={onTogglePlayPause}
|
||||
disabled={isLoading}
|
||||
className='mx-8 bg-white rounded-full p-4'
|
||||
className='mx-4 bg-white rounded-full p-4'
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size={36} color='#121212' />
|
||||
@@ -498,38 +560,42 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Shuffle & Repeat Controls */}
|
||||
<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'>
|
||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
|
||||
<Ionicons
|
||||
name={getRepeatIcon() as any}
|
||||
size={24}
|
||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||
/>
|
||||
{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>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Queue info */}
|
||||
{queue.length > 1 && (
|
||||
<View className='items-center mb-4'>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{queueIndex + 1} of {queue.length}
|
||||
</Text>
|
||||
{/* Volume Slider */}
|
||||
{!isTv && VolumeManager && (
|
||||
<View className='flex-row items-center mb-4'>
|
||||
<Ionicons name='volume-low' size={20} color='#666' />
|
||||
<View className='flex-1 mx-3'>
|
||||
<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>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
export const useFavorite = (item: BaseItemDto) => {
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [isFavorite, setIsFavorite] = useState<boolean | undefined>(
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user