mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
540 lines
16 KiB
TypeScript
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>
|
|
}
|
|
/>
|
|
);
|
|
};
|