import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { BaseItemDto, MediaSourceInfo, } 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 { ActivityIndicator, Dimensions, Platform, ScrollView, TouchableOpacity, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import DraggableFlatList, { type RenderItemParams, ScaleDecorator, } from "react-native-draggable-flatlist"; import { CastButton, CastState } from "react-native-google-cast"; import { useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import TextTicker from "react-native-text-ticker"; import type { VolumeResult } from "react-native-volume-manager"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMusicCast } from "@/hooks/useMusicCast"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { type RepeatMode, useMusicPlayer, } from "@/providers/MusicPlayerProvider"; import { formatBitrate } from "@/utils/bitrate"; import { formatDuration } from "@/utils/time"; // Conditionally require VolumeManager (not available on TV) const VolumeManager = Platform.isTV ? null : require("react-native-volume-manager"); const formatFileSize = (bytes?: number | null) => { if (!bytes) return null; const sizes = ["B", "KB", "MB", "GB"]; if (bytes === 0) return "0 B"; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; }; const formatSampleRate = (sampleRate?: number | null) => { if (!sampleRate) return null; return `${(sampleRate / 1000).toFixed(1)} kHz`; }; 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 [user] = useAtom(userAtom); const router = useRouter(); const insets = useSafeAreaInsets(); const [viewMode, setViewMode] = useState("player"); const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); const { isConnected: isCastConnected, castQueue, castState, } = useMusicCast({ api, userId: user?.Id, }); const { currentTrack, queue, queueIndex, isPlaying, isLoading, progress, duration, repeatMode, shuffleEnabled, mediaSource, isTranscoding, togglePlayPause, next, previous, seek, setRepeatMode, toggleShuffle, jumpToIndex, removeFromQueue, reorderQueue, stop, pause, } = useMusicPlayer(); const { isFavorite, toggleFavorite } = useFavorite( currentTrack ?? ({ Id: "" } as BaseItemDto), ); 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]); // Auto-cast queue when Chromecast becomes connected and pause local playback const prevCastState = useRef(null); useEffect(() => { if ( castState === CastState.CONNECTED && prevCastState.current !== CastState.CONNECTED && queue.length > 0 ) { // Just connected - pause local playback and cast the queue pause(); castQueue({ queue, startIndex: queueIndex }); } prevCastState.current = castState; }, [castState, queue, queueIndex, castQueue, pause]); 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 remainingText = useMemo(() => { const remaining = Math.max(0, duration - progress); const remainingTicks = remaining * 10000000; return `-${formatDuration(remainingTicks)}`; }, [duration, progress]); 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 handleOptionsPress = useCallback(() => { setTrackOptionsOpen(true); }, []); const handleAddToPlaylist = useCallback(() => { setPlaylistPickerOpen(true); }, []); const handleCreateNewPlaylist = useCallback(() => { setCreatePlaylistOpen(true); }, []); 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 ( No track playing ); } return ( {/* Header */} setViewMode("player")} className='px-3 py-1' > Now Playing setViewMode("queue")} className='px-3 py-1' > Queue ({queue.length}) {/* Empty placeholder to balance header layout */} {viewMode === "player" ? ( ) : ( )} ); } interface PlayerViewProps { api: any; currentTrack: BaseItemDto; imageUrl: string | null; sliderProgress: any; sliderMin: any; sliderMax: any; progressText: string; remainingText: string; isPlaying: boolean; isLoading: 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; mediaSource: MediaSourceInfo | null; isTranscoding: boolean; isFavorite: boolean | undefined; onToggleFavorite: () => void; onOptionsPress: () => void; isCastConnected: boolean; } const PlayerView: React.FC = ({ currentTrack, imageUrl, sliderProgress, sliderMin, sliderMax, progressText, remainingText, isPlaying, isLoading, repeatMode, shuffleEnabled, canGoNext, canGoPrevious, onSliderComplete, onTogglePlayPause, onNext, onPrevious, onCycleRepeat, onToggleShuffle, getRepeatIcon, mediaSource, isTranscoding, isFavorite, onToggleFavorite, onOptionsPress, isCastConnected, }) => { const audioStream = useMemo(() => { return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio"); }, [mediaSource]); // Volume slider state const volumeProgress = useSharedValue(0); const volumeMin = useSharedValue(0); const volumeMax = useSharedValue(1); const isTv = Platform.isTV; useEffect(() => { if (isTv || !VolumeManager) return; // Get initial volume VolumeManager.getVolume().then(({ volume }: { volume: number }) => { volumeProgress.value = volume; }); // Listen to volume changes const listener = VolumeManager.addVolumeListener((result: VolumeResult) => { volumeProgress.value = result.volume; }); return () => listener.remove(); }, [isTv, volumeProgress]); const handleVolumeChange = useCallback((value: number) => { if (VolumeManager) { VolumeManager.setVolume(value); } }, []); const fileSize = formatFileSize(mediaSource?.Size); const codec = audioStream?.Codec?.toUpperCase(); const bitrate = formatBitrate(audioStream?.BitRate); const sampleRate = formatSampleRate(audioStream?.SampleRate); const playbackMethod = isTranscoding ? "Transcoding" : "Direct"; const hasAudioStats = mediaSource && (fileSize || codec || bitrate || sampleRate); return ( {/* Album artwork */} {imageUrl ? ( ) : ( )} {/* Track info with actions */} t} > {currentTrack.Name} t} > {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} {/* Audio Stats */} {hasAudioStats && ( {fileSize && } {codec && } } /> {bitrate && bitrate !== "N/A" && ( )} {sampleRate && } )} {/* Progress slider */} null} sliderHeight={8} containerStyle={{ borderRadius: 100 }} renderBubble={() => null} /> {progressText} {remainingText} {/* Main Controls with Shuffle & Repeat */} {isLoading ? ( ) : ( )} {repeatMode === "one" && ( 1 )} {/* Volume Slider */} {!isTv && VolumeManager && ( null} sliderHeight={8} containerStyle={{ borderRadius: 100 }} renderBubble={() => null} /> )} {/* AirPlay & Chromecast Buttons */} {!isTv && ( {/* AirPlay (iOS only) */} {Platform.OS === "ios" && ( )} {/* Chromecast */} )} ); }; interface QueueViewProps { api: any; queue: BaseItemDto[]; queueIndex: number; onJumpToIndex: (index: number) => void; onRemoveFromQueue: (index: number) => void; onReorderQueue: (newQueue: BaseItemDto[]) => void; } const QueueView: React.FC = ({ api, queue, queueIndex, onJumpToIndex, onRemoveFromQueue, onReorderQueue, }) => { const renderQueueItem = useCallback( ({ item, drag, isActive, getIndex }: RenderItemParams) => { const index = getIndex() ?? 0; 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 ( onJumpToIndex(index)} onLongPress={drag} disabled={isActive} className='flex-row items-center px-4 py-3' style={{ opacity: isPast && !isActive ? 0.5 : 1, backgroundColor: isActive ? "#2a2a2a" : isCurrentTrack ? "rgba(147, 52, 233, 0.3)" : "#121212", }} > {/* Drag handle */} {/* Album art */} {imageUrl ? ( ) : ( )} {/* Track info */} {item.Name} {item.Artists?.join(", ") || item.AlbumArtist} {/* Now playing indicator */} {isCurrentTrack && ( )} {/* Remove button (not for current track) */} {!isCurrentTrack && ( onRemoveFromQueue(index)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} className='p-2' > )} ); }, [api, queueIndex, onJumpToIndex, onRemoveFromQueue], ); const handleDragEnd = useCallback( ({ data }: { data: BaseItemDto[] }) => { onReorderQueue(data); }, [onReorderQueue], ); const history = queue.slice(0, queueIndex); return ( `${item.Id}-${index}`} renderItem={renderQueueItem} onDragEnd={handleDragEnd} showsVerticalScrollIndicator={false} ListHeaderComponent={ {history.length > 0 ? "Playing from queue" : "Up next"} } ListEmptyComponent={ Queue is empty } /> ); };