diff --git a/app/(auth)/airplay-player.tsx b/app/(auth)/airplay-player.tsx deleted file mode 100644 index a0b48a10..00000000 --- a/app/(auth)/airplay-player.tsx +++ /dev/null @@ -1,387 +0,0 @@ -/** - * AirPlay Player Modal - * Full-screen player interface for AirPlay (iOS only) - * Similar design to Chromecast player but optimized for Apple ecosystem - */ - -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { router } from "expo-router"; -import { useAtomValue } from "jotai"; -import { useCallback } from "react"; -import { - ActivityIndicator, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withSpring, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAirPlayPlayer } from "@/components/airplay/hooks/useAirPlayPlayer"; -import { Text } from "@/components/common/Text"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { - calculateEndingTime, - formatTime, - getPosterUrl, - truncateTitle, -} from "@/utils/airplay/helpers"; - -export default function AirPlayPlayerScreen() { - const insets = useSafeAreaInsets(); - const api = useAtomValue(apiAtom); - - const { - isConnected, - currentItem, - currentDevice, - progress, - duration, - isPlaying, - togglePlayPause, - seek, - skipForward, - skipBackward, - stop, - } = useAirPlayPlayer(null); - - // Swipe down to dismiss gesture - const translateY = useSharedValue(0); - const context = useSharedValue({ y: 0 }); - - const dismissModal = useCallback(() => { - if (router.canGoBack()) { - router.back(); - } - }, []); - - const panGesture = Gesture.Pan() - .onStart(() => { - context.value = { y: translateY.value }; - }) - .onUpdate((event) => { - if (event.translationY > 0) { - translateY.value = event.translationY; - } - }) - .onEnd((event) => { - if (event.translationY > 100) { - translateY.value = withSpring(500, {}, () => { - runOnJS(dismissModal)(); - }); - } else { - translateY.value = withSpring(0); - } - }); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], - })); - - // Redirect if not connected - if (Platform.OS !== "ios" || !isConnected || !currentItem) { - if (router.canGoBack()) { - router.back(); - } - return null; - } - - const posterUrl = getPosterUrl( - api?.basePath, - currentItem.Id, - currentItem.ImageTags?.Primary, - 300, - 450, - ); - - const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; - const isBuffering = false; // Placeholder - would come from player state - - return ( - - - - {/* Header */} - - - - - - {/* Connection indicator */} - - - - AirPlay - - - - - - - {/* Title and episode info */} - - - {truncateTitle(currentItem.Name || "Unknown", 50)} - - {currentItem.SeriesName && ( - - {currentItem.SeriesName} - {currentItem.ParentIndexNumber && - currentItem.IndexNumber && - ` • S${currentItem.ParentIndexNumber}:E${currentItem.IndexNumber}`} - - )} - - - {/* Poster with buffering overlay */} - - - {posterUrl ? ( - - ) : ( - - - - )} - - {/* Buffering overlay */} - {isBuffering && ( - - - - Buffering... - - - )} - - - - {/* Device info */} - - - - {currentDevice?.name || "AirPlay Device"} - - - - {/* Progress slider */} - - - - - - - {/* Time display */} - - - {formatTime(progress)} - - - Ending at {calculateEndingTime(progress, duration)} - - - {formatTime(duration)} - - - - {/* Playback controls */} - - {/* Rewind 10s */} - skipBackward(10)} style={{ padding: 16 }}> - - - - {/* Play/Pause */} - - - - - {/* Forward 10s */} - skipForward(10)} style={{ padding: 16 }}> - - - - - {/* Stop casting button */} - - - - Stop AirPlay - - - - - - ); -} diff --git a/app/(auth)/chromecast-player.tsx b/app/(auth)/chromecast-player.tsx deleted file mode 100644 index 81016fa8..00000000 --- a/app/(auth)/chromecast-player.tsx +++ /dev/null @@ -1,580 +0,0 @@ -/** - * Full Chromecast Player Modal - * Displays when user taps mini player or cast button during playback - */ - -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { useAtomValue } from "jotai"; -import React, { useCallback, useMemo, useState } from "react"; -import { - ActivityIndicator, - Modal, - Pressable, - useWindowDimensions, - View, -} from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - FadeIn, - FadeOut, - runOnJS, - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useChromecastPlayer } from "@/components/chromecast/hooks/useChromecastPlayer"; -import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; -import { Text } from "@/components/common/Text"; -import { useTrickplay } from "@/hooks/useTrickplay"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { - formatEpisodeInfo, - getPosterUrl, - truncateTitle, -} from "@/utils/chromecast/helpers"; -import { CHROMECAST_CONSTANTS } from "@/utils/chromecast/options"; - -interface ChromecastPlayerProps { - visible: boolean; - onClose: () => void; -} - -export const ChromecastPlayer: React.FC = ({ - visible, - onClose, -}) => { - const insets = useSafeAreaInsets(); - const { height: screenHeight } = useWindowDimensions(); - const api = useAtomValue(apiAtom); - - const { - playerState, - showControls, - currentItem, - nextItem, - stop, - togglePlay, - seek, - skipForward, - skipBackward, - disconnect, - setShowControls, - currentTime, - remainingTime, - endingTime, - showNextEpisodeCountdown, - settings, - } = useChromecastPlayer(); - - const { currentSegment, skipSegment } = useChromecastSegments( - currentItem, - playerState.progress, - ); - - const { calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentItem!); - - const [_showMenu, setShowMenu] = useState(false); - const [_showDeviceSheet, setShowDeviceSheet] = useState(false); - const [_showEpisodeList, setShowEpisodeList] = useState(false); - const [isCollapsed, setIsCollapsed] = useState(false); - - // Slider values - const progress = useSharedValue(playerState.progress); - const min = useSharedValue(0); - const max = useSharedValue(playerState.duration); - const isSeeking = useSharedValue(false); - - // Update slider when player state changes - React.useEffect(() => { - if (!isSeeking.value) { - progress.value = playerState.progress; - } - max.value = playerState.duration; - }, [playerState.progress, playerState.duration, isSeeking]); - - // Swipe down to dismiss gesture - const translateY = useSharedValue(0); - const context = useSharedValue({ y: 0 }); - - const gesture = Gesture.Pan() - .onStart(() => { - context.value = { y: translateY.value }; - }) - .onUpdate((event) => { - if (event.translationY > 0) { - translateY.value = context.value.y + event.translationY; - } - }) - .onEnd((event) => { - if (event.translationY > 100) { - translateY.value = withTiming(screenHeight, {}, () => { - runOnJS(onClose)(); - }); - } else { - translateY.value = withSpring(0); - } - }); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], - })); - - const posterUrl = useMemo(() => { - if (!currentItem || !api) return null; - return getPosterUrl(currentItem, api); - }, [currentItem, api]); - - const handleSliderChange = useCallback( - (value: number) => { - progress.value = value; - if (trickplayInfo && currentItem) { - calculateTrickplayUrl(value); - } - }, - [calculateTrickplayUrl, trickplayInfo, currentItem], - ); - - const handleSliderComplete = useCallback( - (value: number) => { - isSeeking.value = false; - seek(value); - }, - [seek, isSeeking], - ); - - if (!playerState.isConnected || !visible) { - return null; - } - - return ( - - - - - {/* Header - Collapsible */} - - - {/* Collapse arrow */} - setIsCollapsed(!isCollapsed)} - style={{ padding: 4 }} - > - - - - {/* Title and episode info */} - - {currentItem && ( - <> - - {truncateTitle( - currentItem.Name || "Unknown", - isCollapsed ? 50 : 35, - )} - - {!isCollapsed && ( - - {formatEpisodeInfo( - currentItem.ParentIndexNumber, - currentItem.IndexNumber, - )} - {currentItem.SeriesName && - ` • ${truncateTitle(currentItem.SeriesName, 25)}`} - - )} - - )} - - - {/* Connection quality indicator */} - - - - - - - {/* Main content area */} - - {/* Poster */} - - {posterUrl ? ( - - ) : ( - - - - )} - - {/* Buffering indicator */} - {playerState.isBuffering && ( - - - - )} - - - {/* Current segment indicator */} - {currentSegment && ( - - - {currentSegment.type.toUpperCase()} DETECTED - - - )} - - - {/* Bottom controls */} - {showControls && ( - - {/* Time display */} - - - {currentTime} - - - {remainingTime} - - - - {/* Progress slider */} - - { - isSeeking.value = true; - }} - onValueChange={handleSliderChange} - onSlidingComplete={handleSliderComplete} - /> - - - {/* Ending time */} - - Ending at {endingTime} - - - {/* Control buttons row */} - - {/* Skip segment button */} - {currentSegment && ( - skipSegment(seek)} - style={{ - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: "#e50914", - borderRadius: 4, - }} - > - - Skip {currentSegment.type} - - - )} - - {/* Episode list button */} - {currentItem?.Type === "Episode" && ( - setShowEpisodeList(true)} - style={{ padding: 8 }} - > - - - )} - - {/* Settings menu */} - setShowMenu(true)} - style={{ padding: 8 }} - > - - - - {/* Chromecast device info */} - setShowDeviceSheet(true)} - style={{ padding: 8 }} - > - - - - - {/* Playback controls */} - - {/* Rewind */} - - - - - {settings?.rewindSkipTime || 15} - - - - - {/* Play/Pause */} - - - - - {/* Forward */} - - - - - {settings?.forwardSkipTime || 15} - - - - - {/* Stop */} - - - - - - )} - - {/* Next episode countdown */} - {showNextEpisodeCountdown && nextItem && ( - - - Next: {truncateTitle(nextItem.Name || "Unknown", 40)} - - - Starting in{" "} - {Math.ceil( - (playerState.duration - playerState.progress) / 1000, - )} - s - - - )} - - - - - {/* TODO: Add settings menu modal */} - {/* TODO: Add device info sheet modal */} - {/* TODO: Add episode list modal */} - - ); -}; diff --git a/components/airplay/AirPlayMiniPlayer.tsx b/components/airplay/AirPlayMiniPlayer.tsx deleted file mode 100644 index 461ed9a3..00000000 --- a/components/airplay/AirPlayMiniPlayer.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/** - * AirPlay Mini Player - * Compact player bar shown at bottom of screen when AirPlaying - * iOS only component - */ - -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { router } from "expo-router"; -import { useAtomValue } from "jotai"; -import React from "react"; -import { Platform, Pressable, View } from "react-native"; -import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated"; -import { useAirPlayPlayer } from "@/components/airplay/hooks/useAirPlayPlayer"; -import { Text } from "@/components/common/Text"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { formatTime, getPosterUrl } from "@/utils/airplay/helpers"; -import { AIRPLAY_CONSTANTS } from "@/utils/airplay/options"; - -export const AirPlayMiniPlayer: React.FC = () => { - const api = useAtomValue(apiAtom); - const { - isAirPlayAvailable, - isConnected, - currentItem, - currentDevice, - progress, - duration, - isPlaying, - togglePlayPause, - } = useAirPlayPlayer(null); - - // Only show on iOS when connected - if ( - Platform.OS !== "ios" || - !isAirPlayAvailable || - !isConnected || - !currentItem - ) { - return null; - } - - const posterUrl = getPosterUrl( - api?.basePath, - currentItem.Id, - currentItem.ImageTags?.Primary, - 80, - 120, - ); - - const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; - - const handlePress = () => { - router.push("/airplay-player"); - }; - - return ( - - - {/* Progress bar */} - - - - - {/* Content */} - - {/* Poster */} - {posterUrl && ( - - )} - - {/* Info */} - - - {currentItem.Name} - - {currentItem.SeriesName && ( - - {currentItem.SeriesName} - - )} - - - - {currentDevice?.name || "AirPlay"} - - - {formatTime(progress)} / {formatTime(duration)} - - - - - {/* Play/Pause button */} - { - e.stopPropagation(); - togglePlayPause(); - }} - style={{ - padding: 8, - }} - > - - - - - - ); -}; diff --git a/components/airplay/hooks/useAirPlayPlayer.ts b/components/airplay/hooks/useAirPlayPlayer.ts deleted file mode 100644 index 082a79e0..00000000 --- a/components/airplay/hooks/useAirPlayPlayer.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * AirPlay Player Hook - * Manages AirPlay playback state and controls for iOS devices - * - * Note: AirPlay for video is handled natively by AVFoundation/MPV player. - * This hook tracks the state and provides a unified interface for the UI. - */ - -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import type { - AirPlayDevice, - AirPlayPlayerState, -} from "@/utils/airplay/options"; -import { DEFAULT_AIRPLAY_STATE } from "@/utils/airplay/options"; -import { useSettings } from "@/utils/atoms/settings"; - -/** - * Hook to manage AirPlay player state - * - * For iOS video: AirPlay is native - the video player handles streaming - * This hook provides UI state management and progress tracking - */ -export const useAirPlayPlayer = (item: BaseItemDto | null) => { - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const { settings } = useSettings(); - - const [state, setState] = useState(DEFAULT_AIRPLAY_STATE); - const progressIntervalRef = useRef(null); - const controlsTimeoutRef = useRef(null); - - // Check if AirPlay is available (iOS only) - const isAirPlayAvailable = Platform.OS === "ios"; - - // Detect AirPlay connection - // Note: For native video AirPlay, this would be detected from the player - // For now, this is a placeholder for UI state management - const [isConnected, setIsConnected] = useState(false); - const [currentDevice, setCurrentDevice] = useState( - null, - ); - - // Progress tracking - const updateProgress = useCallback( - (progressMs: number, durationMs: number) => { - setState((prev) => ({ - ...prev, - progress: progressMs, - duration: durationMs, - })); - - // Report progress to Jellyfin - if (api && item?.Id && user?.Id && progressMs > 0) { - const progressSeconds = Math.floor(progressMs / 1000); - api.playStateApi - .reportPlaybackProgress({ - playbackProgressInfo: { - ItemId: item.Id, - PositionTicks: progressSeconds * 10000000, - IsPaused: !state.isPlaying, - PlayMethod: "DirectStream", - }, - }) - .catch(console.error); - } - }, - [api, item?.Id, user?.Id, state.isPlaying], - ); - - // Play/Pause controls - const play = useCallback(() => { - setState((prev) => ({ ...prev, isPlaying: true })); - }, []); - - const pause = useCallback(() => { - setState((prev) => ({ ...prev, isPlaying: false })); - }, []); - - const togglePlayPause = useCallback(() => { - setState((prev) => ({ ...prev, isPlaying: !prev.isPlaying })); - }, []); - - // Seek controls - const seek = useCallback((positionMs: number) => { - setState((prev) => ({ ...prev, progress: positionMs })); - }, []); - - const skipForward = useCallback((seconds = 10) => { - setState((prev) => ({ - ...prev, - progress: Math.min(prev.progress + seconds * 1000, prev.duration), - })); - }, []); - - const skipBackward = useCallback((seconds = 10) => { - setState((prev) => ({ - ...prev, - progress: Math.max(prev.progress - seconds * 1000, 0), - })); - }, []); - - // Stop and disconnect - const stop = useCallback(async () => { - setState(DEFAULT_AIRPLAY_STATE); - setIsConnected(false); - setCurrentDevice(null); - - // Report stop to Jellyfin - if (api && item?.Id && user?.Id) { - await api.playStateApi.reportPlaybackStopped({ - playbackStopInfo: { - ItemId: item.Id, - PositionTicks: state.progress * 10000, - }, - }); - } - }, [api, item?.Id, user?.Id, state.progress]); - - // Volume control - const setVolume = useCallback((volume: number) => { - setState((prev) => ({ ...prev, volume: Math.max(0, Math.min(1, volume)) })); - }, []); - - // Controls visibility - const showControls = useCallback(() => { - setState((prev) => ({ ...prev, showControls: true })); - - // Auto-hide after delay - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - if (state.isPlaying) { - setState((prev) => ({ ...prev, showControls: false })); - } - }, 5000); - }, [state.isPlaying]); - - const hideControls = useCallback(() => { - setState((prev) => ({ ...prev, showControls: false })); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }, []); - - // Cleanup - useEffect(() => { - return () => { - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }; - }, []); - - return { - // State - isAirPlayAvailable, - isConnected, - isPlaying: state.isPlaying, - currentItem: item, - currentDevice, - progress: state.progress, - duration: state.duration, - volume: state.volume, - - // Controls - play, - pause, - togglePlayPause, - seek, - skipForward, - skipBackward, - stop, - setVolume, - showControls: showControls, - hideControls, - updateProgress, - - // Device management - setIsConnected, - setCurrentDevice, - }; -}; diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index f858fa84..85d988b9 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -10,7 +10,7 @@ import React from "react"; import { FlatList, Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { truncateTitle } from "@/utils/chromecast/helpers"; +import { truncateTitle } from "@/utils/casting/helpers"; interface ChromecastEpisodeListProps { visible: boolean; diff --git a/components/chromecast/ChromecastMiniPlayer.tsx b/components/chromecast/ChromecastMiniPlayer.tsx deleted file mode 100644 index c52b48dd..00000000 --- a/components/chromecast/ChromecastMiniPlayer.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Mini Chromecast player bar shown at the bottom of the screen - * Similar to music player mini bar - */ - -import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; -import React from "react"; -import { Pressable, View } from "react-native"; -import Animated, { - FadeIn, - FadeOut, - SlideInDown, - SlideOutDown, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Text } from "@/components/common/Text"; -import { formatEpisodeInfo, truncateTitle } from "@/utils/chromecast/helpers"; -import { CHROMECAST_CONSTANTS } from "@/utils/chromecast/options"; -import { useChromecastPlayer } from "./hooks/useChromecastPlayer"; - -export const ChromecastMiniPlayer: React.FC = () => { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { playerState, currentItem, togglePlay, showNextEpisodeCountdown } = - useChromecastPlayer(); - - // Don't show if not connected or no media - if (!playerState.isConnected || !playerState.currentItemId) { - return null; - } - - const handlePress = () => { - router.push("/chromecast-player"); - }; - - const progress = - playerState.duration > 0 - ? (playerState.progress / playerState.duration) * 100 - : 0; - - return ( - - {/* Progress bar */} - - - - - - - {/* Cast icon */} - - - - - {/* Media info */} - - {currentItem && ( - <> - - {truncateTitle(currentItem.Name || "Unknown", 40)} - - - - {formatEpisodeInfo( - currentItem.ParentIndexNumber, - currentItem.IndexNumber, - )} - - {showNextEpisodeCountdown && ( - - Next episode starting... - - )} - - - )} - {!currentItem && ( - <> - - Casting to {playerState.deviceName || "Chromecast"} - - - {playerState.isPlaying ? "Playing" : "Paused"} - - - )} - - - {/* Play/Pause button */} - { - e.stopPropagation(); - togglePlay(); - }} - style={{ - width: 48, - height: 48, - justifyContent: "center", - alignItems: "center", - }} - > - {playerState.isBuffering ? ( - - ) : ( - - )} - - - - - ); -}; diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index 4a53a001..7b43ecfb 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -13,7 +13,7 @@ import type { AudioTrack, MediaSource, SubtitleTrack, -} from "@/utils/chromecast/options"; +} from "@/utils/casting/types"; interface ChromecastSettingsMenuProps { visible: boolean; @@ -39,7 +39,7 @@ const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; export const ChromecastSettingsMenu: React.FC = ({ visible, onClose, - item, + item: _item, // Reserved for future use (technical info display) mediaSources, selectedMediaSource, onMediaSourceChange, diff --git a/components/chromecast/hooks/useChromecastPlayer.ts b/components/chromecast/hooks/useChromecastPlayer.ts index 273aa1df..fc16fa3d 100644 --- a/components/chromecast/hooks/useChromecastPlayer.ts +++ b/components/chromecast/hooks/useChromecastPlayer.ts @@ -17,7 +17,7 @@ import { calculateEndingTime, formatTime, shouldShowNextEpisodeCountdown, -} from "@/utils/chromecast/helpers"; +} from "@/utils/casting/helpers"; import { CHROMECAST_CONSTANTS, type ChromecastPlayerState, @@ -187,8 +187,8 @@ export const useChromecastPlayer = () => { const currentTime = formatTime(playerState.progress); const remainingTime = formatTime(playerState.duration - playerState.progress); const endingTime = calculateEndingTime( - playerState.duration - playerState.progress, - true, // TODO: Add use24HourFormat setting + playerState.progress, + playerState.duration, ); // Next episode countdown diff --git a/components/chromecast/hooks/useChromecastSegments.ts b/components/chromecast/hooks/useChromecastSegments.ts index ab2a6b17..a83ed162 100644 --- a/components/chromecast/hooks/useChromecastSegments.ts +++ b/components/chromecast/hooks/useChromecastSegments.ts @@ -6,10 +6,9 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; -import { useDownloadedFiles } from "@/providers/Downloads/downloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { isWithinSegment } from "@/utils/chromecast/helpers"; +import { isWithinSegment } from "@/utils/casting/helpers"; import type { ChromecastSegmentData } from "@/utils/chromecast/options"; import { useSegments } from "@/utils/segments"; @@ -20,13 +19,12 @@ export const useChromecastSegments = ( ) => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const { downloadedFiles } = useDownloadedFiles(); // Fetch segments from autoskip API const { data: segmentData } = useSegments( item?.Id || "", isOffline, - downloadedFiles, + undefined, // downloadedFiles parameter api, ); @@ -137,18 +135,26 @@ export const useChromecastSegments = ( switch (currentSegment.type) { case "intro": - return settings?.autoSkipIntro === true; + return settings?.skipIntro === "auto"; case "credits": - return settings?.autoSkipCredits === true; + return settings?.skipOutro === "auto"; case "recap": + return settings?.skipRecap === "auto"; case "commercial": + return settings?.skipCommercial === "auto"; case "preview": - // These don't have settings yet, don't auto-skip - return false; + return settings?.skipPreview === "auto"; default: return false; } - }, [currentSegment, settings?.autoSkipIntro, settings?.autoSkipCredits]); + }, [ + currentSegment, + settings?.skipIntro, + settings?.skipOutro, + settings?.skipRecap, + settings?.skipCommercial, + settings?.skipPreview, + ]); return { segments, diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index dfc653d7..93296ab8 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -4,6 +4,7 @@ */ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; @@ -13,7 +14,6 @@ import { useRemoteMediaClient, } from "react-native-google-cast"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; import type { CastPlayerState, CastProtocol } from "@/utils/casting/types"; import { DEFAULT_CAST_STATE } from "@/utils/casting/types"; @@ -23,7 +23,7 @@ import { DEFAULT_CAST_STATE } from "@/utils/casting/types"; export const useCasting = (item: BaseItemDto | null) => { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - const { settings } = useSettings(); + // const { settings } = useSettings(); // TODO: Use for preferences // Chromecast hooks const client = useRemoteMediaClient(); @@ -86,10 +86,10 @@ export const useCasting = (item: BaseItemDto | null) => { if (activeProtocol === "chromecast" && mediaStatus) { setState((prev) => ({ ...prev, - isPlaying: !mediaStatus.isPaused && !mediaStatus.isBuffering, + isPlaying: mediaStatus.playerState === "playing", progress: (mediaStatus.streamPosition || 0) * 1000, duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000, - isBuffering: mediaStatus.isBuffering || false, + isBuffering: mediaStatus.playerState === "buffering", })); } }, [mediaStatus, activeProtocol]); @@ -100,8 +100,9 @@ export const useCasting = (item: BaseItemDto | null) => { const reportProgress = () => { const progressSeconds = Math.floor(state.progress / 1000); - api?.playStateApi - .reportPlaybackProgress({ + const playStateApi = api ? getPlaystateApi(api) : null; + playStateApi + ?.reportPlaybackProgress({ playbackProgressInfo: { ItemId: item.Id, PositionTicks: progressSeconds * 10000000, @@ -184,7 +185,8 @@ export const useCasting = (item: BaseItemDto | null) => { // Report stop to Jellyfin if (api && item?.Id && user?.Id) { - await api.playStateApi.reportPlaybackStopped({ + const playStateApi = getPlaystateApi(api); + await playStateApi.reportPlaybackStopped({ playbackStopInfo: { ItemId: item.Id, PositionTicks: state.progress * 10000, @@ -200,7 +202,7 @@ export const useCasting = (item: BaseItemDto | null) => { async (volume: number) => { const clampedVolume = Math.max(0, Math.min(1, volume)); if (activeProtocol === "chromecast") { - await client?.setVolume(clampedVolume); + await client?.setStreamVolume(clampedVolume); } // TODO: AirPlay volume control setState((prev) => ({ ...prev, volume: clampedVolume })); diff --git a/utils/airplay/helpers.ts b/utils/airplay/helpers.ts deleted file mode 100644 index eb2b05c1..00000000 --- a/utils/airplay/helpers.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * AirPlay Helper Functions - * Utility functions for time formatting, quality checks, and data manipulation - */ - -import type { ConnectionQuality } from "./options"; - -/** - * Format milliseconds to HH:MM:SS or MM:SS - */ -export const formatTime = (ms: number): string => { - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -}; - -/** - * Calculate ending time based on current progress and duration - */ -export const calculateEndingTime = ( - currentMs: number, - durationMs: number, -): string => { - const remainingMs = durationMs - currentMs; - const endTime = new Date(Date.now() + remainingMs); - const hours = endTime.getHours(); - const minutes = endTime.getMinutes(); - const ampm = hours >= 12 ? "PM" : "AM"; - const displayHours = hours % 12 || 12; - - return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`; -}; - -/** - * Determine connection quality based on bitrate - */ -export const getConnectionQuality = (bitrate?: number): ConnectionQuality => { - if (!bitrate) return "good"; - const mbps = bitrate / 1000000; - - if (mbps >= 15) return "excellent"; - if (mbps >= 8) return "good"; - if (mbps >= 4) return "fair"; - return "poor"; -}; - -/** - * Get poster URL for item with specified dimensions - */ -export const getPosterUrl = ( - baseUrl: string | undefined, - itemId: string | undefined, - tag: string | undefined, - width: number, - height: number, -): string | null => { - if (!baseUrl || !itemId) return null; - - const params = new URLSearchParams({ - maxWidth: width.toString(), - maxHeight: height.toString(), - quality: "90", - ...(tag && { tag }), - }); - - return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`; -}; - -/** - * Truncate title to max length with ellipsis - */ -export const truncateTitle = (title: string, maxLength: number): string => { - if (title.length <= maxLength) return title; - return `${title.substring(0, maxLength - 3)}...`; -}; - -/** - * Check if current time is within a segment - */ -export const isWithinSegment = ( - currentMs: number, - segment: { start: number; end: number } | null, -): boolean => { - if (!segment) return false; - const currentSeconds = currentMs / 1000; - return currentSeconds >= segment.start && currentSeconds <= segment.end; -}; - -/** - * Format bitrate to human-readable string - */ -export const formatBitrate = (bitrate: number): string => { - const mbps = bitrate / 1000000; - if (mbps >= 1) { - return `${mbps.toFixed(1)} Mbps`; - } - return `${(bitrate / 1000).toFixed(0)} Kbps`; -}; diff --git a/utils/airplay/options.ts b/utils/airplay/options.ts deleted file mode 100644 index 1c2ab38e..00000000 --- a/utils/airplay/options.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * AirPlay Options and Types - * Configuration constants and type definitions for AirPlay player - */ - -export interface AirPlayDevice { - name: string; - id: string; - type: string; -} - -export interface AirPlayPlayerState { - isConnected: boolean; - isPlaying: boolean; - currentItem: any | null; - currentDevice: AirPlayDevice | null; - progress: number; - duration: number; - volume: number; - showControls: boolean; -} - -export interface AirPlaySegmentData { - intro: { start: number; end: number } | null; - credits: { start: number; end: number } | null; - recap: { start: number; end: number } | null; - commercial: Array<{ start: number; end: number }>; - preview: Array<{ start: number; end: number }>; -} - -export interface AudioTrack { - index: number; - language: string; - codec: string; - displayTitle: string; -} - -export interface SubtitleTrack { - index: number; - language: string; - codec: string; - displayTitle: string; - isForced: boolean; -} - -export interface MediaSource { - id: string; - name: string; - bitrate?: number; - container: string; -} - -export const AIRPLAY_CONSTANTS = { - POSTER_WIDTH: 300, - POSTER_HEIGHT: 450, - ANIMATION_DURATION: 300, - CONTROL_HIDE_DELAY: 5000, - PROGRESS_UPDATE_INTERVAL: 1000, - SEEK_FORWARD_SECONDS: 10, - SEEK_BACKWARD_SECONDS: 10, -} as const; - -export const DEFAULT_AIRPLAY_STATE: AirPlayPlayerState = { - isConnected: false, - isPlaying: false, - currentItem: null, - currentDevice: null, - progress: 0, - duration: 0, - volume: 0.5, - showControls: true, -}; - -export type ConnectionQuality = "excellent" | "good" | "fair" | "poor"; diff --git a/utils/casting/helpers.ts b/utils/casting/helpers.ts index 079866ef..0edd4958 100644 --- a/utils/casting/helpers.ts +++ b/utils/casting/helpers.ts @@ -128,3 +128,35 @@ export const getProtocolIcon = ( return "logo-apple"; } }; + +/** + * Format episode info (e.g., "S1 E1" or "Episode 1") + */ +export const formatEpisodeInfo = ( + seasonNumber?: number | null, + episodeNumber?: number | null, +): string => { + if ( + seasonNumber !== undefined && + seasonNumber !== null && + episodeNumber !== undefined && + episodeNumber !== null + ) { + return `S${seasonNumber} E${episodeNumber}`; + } + if (episodeNumber !== undefined && episodeNumber !== null) { + return `Episode ${episodeNumber}`; + } + return ""; +}; + +/** + * Check if we should show next episode countdown + */ +export const shouldShowNextEpisodeCountdown = ( + remainingMs: number, + hasNextEpisode: boolean, + countdownStartSeconds: number, +): boolean => { + return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000; +};