Files
streamyfin/app/(auth)/now-playing.tsx

540 lines
16 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Dimensions,
FlatList,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
type RepeatMode,
useMusicPlayer,
} from "@/providers/MusicPlayerProvider";
import { formatDuration } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue";
export default function NowPlayingScreen() {
const [api] = useAtom(apiAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const [viewMode, setViewMode] = useState<ViewMode>("player");
const {
currentTrack,
queue,
queueIndex,
isPlaying,
progress,
duration,
repeatMode,
shuffleEnabled,
togglePlayPause,
next,
previous,
seek,
setRepeatMode,
toggleShuffle,
jumpToIndex,
removeFromQueue,
stop,
} = useMusicPlayer();
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(1);
useEffect(() => {
sliderProgress.value = progress;
}, [progress, sliderProgress]);
useEffect(() => {
sliderMax.value = duration > 0 ? duration : 1;
}, [duration, sliderMax]);
const imageUrl = useMemo(() => {
if (!api || !currentTrack) return null;
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
if (albumId) {
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
}
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
}, [api, currentTrack]);
const progressText = useMemo(() => {
const progressTicks = progress * 10000000;
return formatDuration(progressTicks);
}, [progress]);
const durationText = useMemo(() => {
const durationTicks = duration * 10000000;
return formatDuration(durationTicks);
}, [duration]);
const handleSliderComplete = useCallback(
(value: number) => {
seek(value);
},
[seek],
);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const handleStop = useCallback(() => {
stop();
router.back();
}, [stop, router]);
const cycleRepeatMode = useCallback(() => {
const modes: RepeatMode[] = ["off", "all", "one"];
const currentIndex = modes.indexOf(repeatMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setRepeatMode(nextMode);
}, [repeatMode, setRepeatMode]);
const getRepeatIcon = (): string => {
switch (repeatMode) {
case "one":
return "repeat";
case "all":
return "repeat";
default:
return "repeat";
}
};
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
if (!currentTrack) {
return (
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
);
}
return (
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleStop}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={24} color='#666' />
</TouchableOpacity>
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
durationText={durationText}
isPlaying={isPlaying}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
queue={queue}
queueIndex={queueIndex}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
/>
)}
</View>
);
}
interface PlayerViewProps {
api: any;
currentTrack: BaseItemDto;
imageUrl: string | null;
sliderProgress: any;
sliderMin: any;
sliderMax: any;
progressText: string;
durationText: string;
isPlaying: boolean;
repeatMode: RepeatMode;
shuffleEnabled: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
onSliderComplete: (value: number) => void;
onTogglePlayPause: () => void;
onNext: () => void;
onPrevious: () => void;
onCycleRepeat: () => void;
onToggleShuffle: () => void;
getRepeatIcon: () => string;
queue: BaseItemDto[];
queueIndex: number;
}
const PlayerView: React.FC<PlayerViewProps> = ({
currentTrack,
imageUrl,
sliderProgress,
sliderMin,
sliderMax,
progressText,
durationText,
isPlaying,
repeatMode,
shuffleEnabled,
canGoNext,
canGoPrevious,
onSliderComplete,
onTogglePlayPause,
onNext,
onPrevious,
onCycleRepeat,
onToggleShuffle,
getRepeatIcon,
queue,
queueIndex,
}) => {
return (
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
{/* Album artwork */}
<View
className='self-center mb-8 mt-4'
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
}}
>
{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={80} color='#666' />
</View>
)}
</View>
{/* Track info */}
<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>
{/* Progress slider */}
<View className='mb-4'>
<Slider
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: "#9334E9",
bubbleBackgroundColor: "#9334E9",
bubbleTextColor: "#fff",
}}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
onSlidingComplete={onSliderComplete}
thumbWidth={16}
sliderHeight={6}
containerStyle={{ borderRadius: 10 }}
renderBubble={() => null}
/>
<View className='flex flex-row justify-between px-1 mt-2'>
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
</View>
</View>
{/* Main Controls */}
<View className='flex flex-row items-center justify-center mb-4'>
<TouchableOpacity
onPress={onPrevious}
disabled={!canGoPrevious}
className='p-4'
style={{ opacity: canGoPrevious ? 1 : 0.3 }}
>
<Ionicons name='play-skip-back' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={onTogglePlayPause}
className='mx-8 bg-white rounded-full p-4'
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='#121212'
style={isPlaying ? {} : { marginLeft: 4 }}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onNext}
disabled={!canGoNext}
className='p-4'
style={{ opacity: canGoNext ? 1 : 0.3 }}
>
<Ionicons name='play-skip-forward' size={32} color='white' />
</TouchableOpacity>
</View>
{/* Shuffle & Repeat Controls */}
<View className='flex flex-row items-center justify-center mb-6'>
<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
name={getRepeatIcon() as any}
size={24}
color={repeatMode !== "off" ? "#9334E9" : "#666"}
/>
{repeatMode === "one" && (
<View className='absolute -top-1 -right-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-8'>
<Text className='text-neutral-500 text-sm'>
{queueIndex + 1} of {queue.length}
</Text>
</View>
)}
</ScrollView>
);
};
interface QueueViewProps {
api: any;
queue: BaseItemDto[];
queueIndex: number;
onJumpToIndex: (index: number) => void;
onRemoveFromQueue: (index: number) => void;
}
const QueueView: React.FC<QueueViewProps> = ({
api,
queue,
queueIndex,
onJumpToIndex,
onRemoveFromQueue,
}) => {
const renderQueueItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isCurrentTrack = index === queueIndex;
const isPast = index < queueIndex;
const albumId = item.AlbumId || item.ParentId;
const imageUrl = api
? albumId
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
: null;
return (
<TouchableOpacity
onPress={() => onJumpToIndex(index)}
className={`flex-row items-center px-4 py-3 ${isCurrentTrack ? "bg-purple-900/30" : ""}`}
style={{ opacity: isPast ? 0.5 : 1 }}
>
{/* Track number / Now playing indicator */}
<View className='w-8 items-center'>
{isCurrentTrack ? (
<Ionicons name='musical-note' size={16} color='#9334E9' />
) : (
<Text className='text-neutral-500 text-sm'>{index + 1}</Text>
)}
</View>
{/* Album art */}
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center'>
<Ionicons name='musical-note' size={16} color='#666' />
</View>
)}
</View>
{/* Track info */}
<View className='flex-1 mr-2'>
<Text
numberOfLines={1}
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
>
{item.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</View>
{/* Remove button (not for current track) */}
{!isCurrentTrack && (
<TouchableOpacity
onPress={() => onRemoveFromQueue(index)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={20} color='#666' />
</TouchableOpacity>
)}
</TouchableOpacity>
);
},
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
);
const _upNext = queue.slice(queueIndex + 1);
const history = queue.slice(0, queueIndex);
return (
<FlatList
data={queue}
keyExtractor={(item, index) => `${item.Id}-${index}`}
renderItem={renderQueueItem}
showsVerticalScrollIndicator={false}
initialScrollIndex={queueIndex > 2 ? queueIndex - 2 : 0}
getItemLayout={(_, index) => ({
length: 72,
offset: 72 * index,
index,
})}
ListHeaderComponent={
<View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"}
</Text>
</View>
}
ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text>
</View>
}
/>
);
};