mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations. Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images. Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
459 lines
14 KiB
TypeScript
459 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(`/music/artist/${artistId}`);
|
|
}
|
|
}, [track?.ArtistItems, router, setOpen]);
|
|
|
|
const handleGoToAlbum = useCallback(() => {
|
|
const albumId = track?.AlbumId || track?.ParentId;
|
|
if (albumId) {
|
|
setOpen(false);
|
|
router.push(`/music/album/${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",
|
|
},
|
|
});
|