Files
streamyfin/components/music/TrackOptionsSheet.tsx
2026-01-11 17:38:41 +01:00

465 lines
14 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import {
audioStorageEvents,
deleteTrack,
downloadTrack,
isCached,
isPermanentDownloading,
isPermanentlyDownloaded,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
track: BaseItemDto | null;
onAddToPlaylist: () => void;
playlistId?: string;
onRemoveFromPlaylist?: () => void;
}
export const TrackOptionsSheet: React.FC<Props> = ({
open,
setOpen,
track,
onAddToPlaylist,
playlistId,
onRemoveFromPlaylist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const { playNext, addToQueue } = useMusicPlayer();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
// Counter to trigger re-evaluation of download status when storage changes
const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
// Listen for storage events to update download status
useEffect(() => {
const handleComplete = (event: { itemId: string }) => {
if (event.itemId === track?.Id) {
setStorageUpdateCounter((c) => c + 1);
}
};
audioStorageEvents.on("complete", handleComplete);
return () => {
audioStorageEvents.off("complete", handleComplete);
};
}, [track?.Id]);
// Force re-evaluation of cache status when track changes
useEffect(() => {
setStorageUpdateCounter((c) => c + 1);
}, [track?.Id]);
// Use a placeholder item for useFavorite when track is null
const { isFavorite, toggleFavorite } = useFavorite(
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
);
// Check download status (storageUpdateCounter triggers re-evaluation when download completes)
const isAlreadyDownloaded = useMemo(
() => isPermanentlyDownloaded(track?.Id),
[track?.Id, storageUpdateCounter],
);
const isOnlyCached = useMemo(
() => isCached(track?.Id),
[track?.Id, storageUpdateCounter],
);
const isCurrentlyDownloading = useMemo(
() => isPermanentDownloading(track?.Id),
[track?.Id, storageUpdateCounter],
);
const imageUrl = useMemo(() => {
if (!track) return null;
const albumId = track.AlbumId || track.ParentId;
if (albumId) {
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
}
return getPrimaryImageUrl({ api, item: track });
}, [api, track]);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handlePlayNext = useCallback(() => {
if (track) {
playNext(track);
setOpen(false);
}
}, [track, playNext, setOpen]);
const handleAddToQueue = useCallback(() => {
if (track) {
addToQueue(track);
setOpen(false);
}
}, [track, addToQueue, setOpen]);
const handleAddToPlaylist = useCallback(() => {
setOpen(false);
setTimeout(() => {
onAddToPlaylist();
}, 300);
}, [onAddToPlaylist, setOpen]);
const handleRemoveFromPlaylist = useCallback(() => {
if (onRemoveFromPlaylist) {
onRemoveFromPlaylist();
setOpen(false);
}
}, [onRemoveFromPlaylist, setOpen]);
const handleDownload = useCallback(async () => {
if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
setIsDownloadingTrack(true);
try {
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
} catch {
// Silent fail
}
setIsDownloadingTrack(false);
setOpen(false);
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
const handleDelete = useCallback(async () => {
if (!track?.Id) return;
await deleteTrack(track.Id);
setStorageUpdateCounter((c) => c + 1);
setOpen(false);
}, [track?.Id, setOpen]);
const handleGoToArtist = useCallback(() => {
const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) {
setOpen(false);
router.push({
pathname: "/music/artist/[artistId]",
params: { artistId },
});
}
}, [track?.ArtistItems, router, setOpen]);
const handleGoToAlbum = useCallback(() => {
const albumId = track?.AlbumId || track?.ParentId;
if (albumId) {
setOpen(false);
router.push({
pathname: "/music/album/[albumId]",
params: { albumId },
});
}
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
const handleToggleFavorite = useCallback(() => {
if (track) {
toggleFavorite();
setOpen(false);
}
}, [track, toggleFavorite, setOpen]);
// Check if navigation options are available
const hasArtist = !!track?.ArtistItems?.[0]?.Id;
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
if (!track) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: insets.bottom,
}}
>
{/* Track Info Header */}
<View className='flex-row items-center mb-6 px-2'>
<View
style={{
width: 56,
height: 56,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
marginRight: 12,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={24} color='#737373' />
</View>
)}
</View>
<View className='flex-1'>
<Text
numberOfLines={1}
className='text-white font-semibold text-base'
>
{track.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{track.Artists?.join(", ") || track.AlbumArtist}
</Text>
</View>
</View>
{/* Playback Options */}
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
<TouchableOpacity
onPress={handlePlayNext}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='play-forward' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.play_next")}
</Text>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleAddToQueue}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='list' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.add_to_queue")}
</Text>
</TouchableOpacity>
</View>
{/* Library Options */}
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
<TouchableOpacity
onPress={handleToggleFavorite}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={22}
color={isFavorite ? "#ec4899" : "white"}
/>
<Text className='text-white ml-4 text-base'>
{isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites")}
</Text>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleAddToPlaylist}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='albums-outline' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.add_to_playlist")}
</Text>
</TouchableOpacity>
{playlistId && (
<>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleRemoveFromPlaylist}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='trash-outline' size={22} color='#ef4444' />
<Text className='text-red-500 ml-4 text-base'>
{t("music.track_options.remove_from_playlist")}
</Text>
</TouchableOpacity>
</>
)}
<View style={styles.separator} />
<TouchableOpacity
onPress={handleDownload}
disabled={
isAlreadyDownloaded ||
isCurrentlyDownloading ||
isDownloadingTrack
}
className='flex-row items-center px-4 py-3.5'
>
{isCurrentlyDownloading || isDownloadingTrack ? (
<ActivityIndicator size={22} color='white' />
) : (
<Ionicons
name={
isAlreadyDownloaded ? "checkmark-circle" : "download-outline"
}
size={22}
color={isAlreadyDownloaded ? "#22c55e" : "white"}
/>
)}
<Text
className={`ml-4 text-base ${isAlreadyDownloaded ? "text-green-500" : "text-white"}`}
>
{isCurrentlyDownloading || isDownloadingTrack
? t("music.track_options.downloading")
: isAlreadyDownloaded
? t("music.track_options.downloaded")
: t("music.track_options.download")}
</Text>
</TouchableOpacity>
{isOnlyCached && !isAlreadyDownloaded && (
<>
<View style={styles.separator} />
<View className='flex-row items-center px-4 py-3.5'>
<Ionicons name='cloud-done-outline' size={22} color='#737373' />
<Text className='text-neutral-500 ml-4 text-base'>
{t("music.track_options.cached")}
</Text>
</View>
</>
)}
{(isAlreadyDownloaded || isOnlyCached) && (
<>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleDelete}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='trash-outline' size={22} color='#ef4444' />
<Text className='text-red-500 ml-4 text-base'>
{isAlreadyDownloaded
? t("music.track_options.delete_download")
: t("music.track_options.delete_cache")}
</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Navigation Options */}
{(hasArtist || hasAlbum) && (
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
{hasArtist && (
<>
<TouchableOpacity
onPress={handleGoToArtist}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='person-outline' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.go_to_artist")}
</Text>
</TouchableOpacity>
{hasAlbum && <View style={styles.separator} />}
</>
)}
{hasAlbum && (
<TouchableOpacity
onPress={handleGoToAlbum}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='disc-outline' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.go_to_album")}
</Text>
</TouchableOpacity>
)}
</View>
)}
</BottomSheetView>
</BottomSheetModal>
);
};
const styles = StyleSheet.create({
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#404040",
},
});