Files
streamyfin/components/music/MusicTrackItem.tsx

227 lines
7.1 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import {
audioStorageEvents,
getLocalPath,
isCached,
isPermanentDownloading,
isPermanentlyDownloaded,
} from "@/providers/AudioStorage";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatDuration } from "@/utils/time";
interface Props {
track: BaseItemDto;
index?: number;
queue?: BaseItemDto[];
showArtwork?: boolean;
onOptionsPress?: (track: BaseItemDto) => void;
}
export const MusicTrackItem: React.FC<Props> = ({
track,
index: _index,
queue,
showArtwork = true,
onOptionsPress,
}) => {
const [api] = useAtom(apiAtom);
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
useMusicPlayer();
const { isConnected, serverConnected } = useNetworkStatus();
const haptic = useHaptic("light");
const imageUrl = useMemo(() => {
const albumId = track.AlbumId || track.ParentId;
if (albumId) {
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
}
return getPrimaryImageUrl({ api, item: track });
}, [api, track]);
const isCurrentTrack = currentTrack?.Id === track.Id;
const isTrackLoading = loadingTrackId === track.Id;
// Track download status with reactivity to completion events
const [downloadStatus, setDownloadStatus] = useState<
"none" | "downloading" | "downloaded" | "cached"
>(() => {
if (isPermanentlyDownloaded(track.Id)) return "downloaded";
if (isPermanentDownloading(track.Id)) return "downloading";
if (isCached(track.Id)) return "cached";
return "none";
});
// Listen for download completion/error events
useEffect(() => {
const onComplete = (event: { itemId: string; permanent: boolean }) => {
if (event.itemId === track.Id) {
setDownloadStatus(event.permanent ? "downloaded" : "cached");
}
};
const onError = (event: { itemId: string }) => {
if (event.itemId === track.Id) {
setDownloadStatus("none");
}
};
audioStorageEvents.on("complete", onComplete);
audioStorageEvents.on("error", onError);
return () => {
audioStorageEvents.off("complete", onComplete);
audioStorageEvents.off("error", onError);
};
}, [track.Id]);
// Re-check status when track changes (for list item recycling)
useEffect(() => {
if (isPermanentlyDownloaded(track.Id)) {
setDownloadStatus("downloaded");
} else if (isPermanentDownloading(track.Id)) {
setDownloadStatus("downloading");
} else if (isCached(track.Id)) {
setDownloadStatus("cached");
} else {
setDownloadStatus("none");
}
}, [track.Id]);
const _isDownloaded = downloadStatus === "downloaded";
// Check if available locally (either cached or permanently downloaded)
const isAvailableLocally = !!getLocalPath(track.Id);
// Consider offline if either no network connection OR server is unreachable
const isOffline = !isConnected || serverConnected === false;
const isUnavailableOffline = isOffline && !isAvailableLocally;
const duration = useMemo(() => {
if (!track.RunTimeTicks) return "";
return formatDuration(track.RunTimeTicks);
}, [track.RunTimeTicks]);
const handlePress = useCallback(() => {
if (isUnavailableOffline) return;
playTrack(track, queue);
}, [playTrack, track, queue, isUnavailableOffline]);
const handleLongPress = useCallback(() => {
onOptionsPress?.(track);
}, [onOptionsPress, track]);
const handleOptionsPress = useCallback(() => {
haptic();
onOptionsPress?.(track);
}, [haptic, onOptionsPress, track]);
return (
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
disabled={isUnavailableOffline}
className={`flex-row items-center py-1.5 pl-4 pr-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
>
{/* Album artwork */}
{showArtwork && (
<View
style={{
width: 44,
height: 44,
borderRadius: 4,
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={18} color='#737373' />
</View>
)}
{isTrackLoading && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
alignItems: "center",
justifyContent: "center",
}}
>
<ActivityIndicator size='small' color='white' />
</View>
)}
</View>
)}
{/* Track info */}
<View className='flex-1 mr-3'>
<View className='flex-row items-center'>
{isCurrentTrack && isPlaying && <AnimatedEqualizer />}
<Text
numberOfLines={1}
className={`flex-1 text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
>
{track.Name}
</Text>
</View>
<Text numberOfLines={1} className='text-neutral-500 text-xs mt-0.5'>
{track.Artists?.join(", ") || track.AlbumArtist}
</Text>
</View>
{/* Download/cache status indicator */}
{downloadStatus === "downloading" && (
<ActivityIndicator
size={14}
color='#9334E9'
style={{ marginRight: 8 }}
/>
)}
{downloadStatus === "downloaded" && (
<Ionicons
name='checkmark-circle'
size={14}
color='#22c55e'
style={{ marginRight: 8 }}
/>
)}
{/* Duration */}
<Text className='text-neutral-500 text-xs'>{duration}</Text>
{/* Options button */}
{onOptionsPress && (
<TouchableOpacity
onPress={handleOptionsPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='pl-3 py-1'
>
<Ionicons name='ellipsis-vertical' size={16} color='#737373' />
</TouchableOpacity>
)}
</TouchableOpacity>
);
};