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;
+};