diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx
index 123de341..cec416e0 100644
--- a/app/(auth)/casting-player.tsx
+++ b/app/(auth)/casting-player.tsx
@@ -7,7 +7,7 @@ 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 { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -17,6 +17,10 @@ import Animated, {
withSpring,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
+import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
+import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
+import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
import { Text } from "@/components/common/Text";
import { useCasting } from "@/hooks/useCasting";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -26,6 +30,7 @@ import {
getPosterUrl,
getProtocolIcon,
getProtocolName,
+ shouldShowNextEpisodeCountdown,
truncateTitle,
} from "@/utils/casting/helpers";
import { PROTOCOL_COLORS } from "@/utils/casting/types";
@@ -47,8 +52,19 @@ export default function CastingPlayerScreen() {
skipForward,
skipBackward,
stop,
+ setVolume,
+ volume,
} = useCasting(null);
+ // Modal states
+ const [showEpisodeList, setShowEpisodeList] = useState(false);
+ const [showDeviceSheet, setShowDeviceSheet] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+
+ // Segment detection (skip intro/credits)
+ const { currentSegment, skipIntro, skipCredits, skipSegment } =
+ useChromecastSegments(currentItem, progress, false);
+
// Swipe down to dismiss gesture
const translateY = useSharedValue(0);
const context = useSharedValue({ y: 0 });
@@ -82,6 +98,46 @@ export default function CastingPlayerScreen() {
transform: [{ translateY: translateY.value }],
}));
+ // Memoize expensive calculations (before early return)
+ const posterUrl = useMemo(
+ () =>
+ getPosterUrl(
+ api?.basePath,
+ currentItem?.Id,
+ currentItem?.ImageTags?.Primary,
+ 300,
+ 450,
+ ),
+ [api?.basePath, currentItem?.Id, currentItem?.ImageTags?.Primary],
+ );
+
+ const progressPercent = useMemo(
+ () => (duration > 0 ? (progress / duration) * 100 : 0),
+ [progress, duration],
+ );
+
+ const protocolColor = useMemo(
+ () => (protocol ? PROTOCOL_COLORS[protocol] : "#666"),
+ [protocol],
+ );
+
+ const protocolIcon = useMemo(
+ () => (protocol ? getProtocolIcon(protocol) : ("tv" as const)),
+ [protocol],
+ );
+
+ const protocolName = useMemo(
+ () => (protocol ? getProtocolName(protocol) : "Unknown"),
+ [protocol],
+ );
+
+ const showNextEpisode = useMemo(() => {
+ if (currentItem?.Type !== "Episode") return false;
+ const remaining = duration - progress;
+ const hasNextEpisode = false; // TODO: Detect if next episode exists
+ return shouldShowNextEpisodeCountdown(remaining, hasNextEpisode, 30);
+ }, [currentItem?.Type, duration, progress]);
+
// Redirect if not connected
if (!isConnected || !currentItem || !protocol) {
if (router.canGoBack()) {
@@ -90,19 +146,6 @@ export default function CastingPlayerScreen() {
return null;
}
- const posterUrl = getPosterUrl(
- api?.basePath,
- currentItem.Id,
- currentItem.ImageTags?.Primary,
- 300,
- 450,
- );
-
- const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
- const protocolColor = PROTOCOL_COLORS[protocol];
- const protocolIcon = getProtocolIcon(protocol);
- const protocolName = getProtocolName(protocol);
-
return (
{/* Connection indicator */}
- setShowDeviceSheet(true)}
style={{
flexDirection: "row",
alignItems: "center",
@@ -168,9 +212,14 @@ export default function CastingPlayerScreen() {
>
{protocolName}
-
+
-
+ setShowSettings(true)}
+ style={{ padding: 8, marginRight: -8 }}
+ >
+
+
{/* Title and episode info */}
@@ -323,6 +372,78 @@ export default function CastingPlayerScreen() {
+ {/* Segment skip button (intro/credits) */}
+ {currentSegment && (
+
+ {
+ if (currentSegment.type === "intro") {
+ skipIntro(null as any); // TODO: Get RemoteMediaClient from useCasting
+ } else if (currentSegment.type === "credits") {
+ skipCredits(null as any);
+ } else {
+ skipSegment(null as any);
+ }
+ }}
+ style={{
+ backgroundColor: protocolColor,
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ borderRadius: 8,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ }}
+ >
+
+
+ Skip{" "}
+ {currentSegment.type.charAt(0).toUpperCase() +
+ currentSegment.type.slice(1)}
+
+
+
+ )}
+
+ {/* Next episode countdown */}
+ {showNextEpisode && (
+
+
+
+
+
+ Next Episode Starting Soon
+
+
+ {Math.ceil((duration - progress) / 1000)}s remaining
+
+
+ {
+ // TODO: Cancel auto-play
+ }}
+ style={{ marginLeft: 8 }}
+ >
+
+
+
+
+ )}
+
{/* Playback controls */}
@@ -383,7 +504,90 @@ export default function CastingPlayerScreen() {
Stop Casting
+
+ {/* Episode list button (for TV shows) */}
+ {currentItem.Type === "Episode" && (
+ setShowEpisodeList(true)}
+ style={{
+ backgroundColor: "#1a1a1a",
+ padding: 16,
+ borderRadius: 12,
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 8,
+ marginBottom: 24,
+ }}
+ >
+
+
+ Episodes
+
+
+ )}
+
+ {/* Modals */}
+ setShowDeviceSheet(false)}
+ device={
+ currentDevice && protocol === "chromecast"
+ ? ({
+ deviceId: currentDevice.id,
+ friendlyName: currentDevice.name,
+ } as any)
+ : null
+ }
+ onDisconnect={stop}
+ volume={volume}
+ onVolumeChange={async (vol) => setVolume(vol)}
+ />
+
+ setShowEpisodeList(false)}
+ currentItem={currentItem}
+ episodes={[]} // TODO: Fetch episodes from series
+ onSelectEpisode={(episode) => {
+ // TODO: Load new episode
+ console.log("Selected episode:", episode.Name);
+ }}
+ />
+
+ setShowSettings(false)}
+ item={currentItem}
+ mediaSources={[]} // TODO: Get from media source selector
+ selectedMediaSource={null}
+ onMediaSourceChange={(source) => {
+ // TODO: Change quality
+ console.log("Changed media source:", source);
+ }}
+ audioTracks={[]} // TODO: Get from player
+ selectedAudioTrack={null}
+ onAudioTrackChange={(track) => {
+ // TODO: Change audio track
+ console.log("Changed audio track:", track);
+ }}
+ subtitleTracks={[]} // TODO: Get from player
+ selectedSubtitleTrack={null}
+ onSubtitleTrackChange={(track) => {
+ // TODO: Change subtitle track
+ console.log("Changed subtitle track:", track);
+ }}
+ playbackSpeed={1.0}
+ onPlaybackSpeedChange={(speed) => {
+ // TODO: Change playback speed
+ console.log("Changed playback speed:", speed);
+ }}
+ showTechnicalInfo={false}
+ onToggleTechnicalInfo={() => {
+ // TODO: Toggle technical info
+ }}
+ />
);
diff --git a/components/chromecast/hooks/useChromecastPlayer.ts b/components/chromecast/hooks/useChromecastPlayer.ts
deleted file mode 100644
index fc16fa3d..00000000
--- a/components/chromecast/hooks/useChromecastPlayer.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-/**
- * Main Chromecast player hook - handles all playback logic and state
- */
-
-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 {
- useCastDevice,
- useMediaStatus,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import {
- calculateEndingTime,
- formatTime,
- shouldShowNextEpisodeCountdown,
-} from "@/utils/casting/helpers";
-import {
- CHROMECAST_CONSTANTS,
- type ChromecastPlayerState,
- DEFAULT_CHROMECAST_STATE,
-} from "@/utils/chromecast/options";
-
-export const useChromecastPlayer = () => {
- const client = useRemoteMediaClient();
- const castDevice = useCastDevice();
- const mediaStatus = useMediaStatus();
- const api = useAtomValue(apiAtom);
- const user = useAtomValue(userAtom);
- const { settings } = useSettings();
-
- const [playerState, setPlayerState] = useState(
- DEFAULT_CHROMECAST_STATE,
- );
- const [showControls, setShowControls] = useState(true);
- const [currentItem, _setCurrentItem] = useState(null);
- const [nextItem, _setNextItem] = useState(null);
-
- const lastReportedProgressRef = useRef(0);
- const controlsTimeoutRef = useRef(null);
-
- // Update player state from media status
- useEffect(() => {
- if (!mediaStatus) {
- setPlayerState(DEFAULT_CHROMECAST_STATE);
- return;
- }
-
- const streamPosition = (mediaStatus.streamPosition || 0) * 1000; // Convert to ms
- const duration = (mediaStatus.mediaInfo?.streamDuration || 0) * 1000;
-
- setPlayerState((prev) => ({
- ...prev,
- isConnected: !!castDevice,
- deviceName: castDevice?.friendlyName || castDevice?.deviceId || null,
- isPlaying: mediaStatus.playerState === "playing",
- isPaused: mediaStatus.playerState === "paused",
- isStopped: mediaStatus.playerState === "idle",
- isBuffering: mediaStatus.playerState === "buffering",
- progress: streamPosition,
- duration,
- currentItemId: mediaStatus.mediaInfo?.contentId || null,
- }));
- }, [mediaStatus, castDevice]);
-
- // Report playback progress to Jellyfin
- useEffect(() => {
- if (
- !api ||
- !user?.Id ||
- !mediaStatus ||
- !mediaStatus.mediaInfo?.contentId
- ) {
- return;
- }
-
- const streamPosition = mediaStatus.streamPosition || 0;
-
- // Report every 10 seconds
- if (
- Math.abs(streamPosition - lastReportedProgressRef.current) <
- CHROMECAST_CONSTANTS.PROGRESS_REPORT_INTERVAL
- ) {
- return;
- }
-
- const contentId = mediaStatus.mediaInfo.contentId;
- const positionTicks = Math.floor(streamPosition * 10000000);
- const isPaused = mediaStatus.playerState === "paused";
- const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
- const isTranscoding = streamUrl.includes("m3u8");
-
- getPlaystateApi(api)
- .reportPlaybackProgress({
- playbackProgressInfo: {
- ItemId: contentId,
- PositionTicks: positionTicks,
- IsPaused: isPaused,
- PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
- PlaySessionId: contentId,
- },
- })
- .then(() => {
- lastReportedProgressRef.current = streamPosition;
- })
- .catch((error) => {
- console.error("Failed to report Chromecast progress:", error);
- });
- }, [
- api,
- user?.Id,
- mediaStatus?.streamPosition,
- mediaStatus?.mediaInfo?.contentId,
- mediaStatus?.playerState,
- ]);
-
- // Auto-hide controls
- const resetControlsTimeout = useCallback(() => {
- if (controlsTimeoutRef.current) {
- clearTimeout(controlsTimeoutRef.current);
- }
-
- setShowControls(true);
- controlsTimeoutRef.current = setTimeout(() => {
- setShowControls(false);
- }, CHROMECAST_CONSTANTS.CONTROLS_TIMEOUT);
- }, []);
-
- // Playback controls
- const play = useCallback(async () => {
- await client?.play();
- }, [client]);
-
- const pause = useCallback(async () => {
- await client?.pause();
- }, [client]);
-
- const stop = useCallback(async () => {
- await client?.stop();
- }, [client]);
-
- const togglePlay = useCallback(async () => {
- if (playerState.isPlaying) {
- await pause();
- } else {
- await play();
- }
- resetControlsTimeout();
- }, [playerState.isPlaying, play, pause, resetControlsTimeout]);
-
- const seek = useCallback(
- async (positionMs: number) => {
- await client?.seek({ position: positionMs / 1000 });
- resetControlsTimeout();
- },
- [client, resetControlsTimeout],
- );
-
- const skipForward = useCallback(async () => {
- const skipTime =
- settings?.forwardSkipTime || CHROMECAST_CONSTANTS.SKIP_FORWARD_TIME;
- const newPosition = playerState.progress + skipTime * 1000;
- await seek(Math.min(newPosition, playerState.duration));
- }, [
- playerState.progress,
- playerState.duration,
- seek,
- settings?.forwardSkipTime,
- ]);
-
- const skipBackward = useCallback(async () => {
- const skipTime =
- settings?.rewindSkipTime || CHROMECAST_CONSTANTS.SKIP_BACKWARD_TIME;
- const newPosition = playerState.progress - skipTime * 1000;
- await seek(Math.max(newPosition, 0));
- }, [playerState.progress, seek, settings?.rewindSkipTime]);
-
- const disconnect = useCallback(async () => {
- await client?.stop();
- setPlayerState(DEFAULT_CHROMECAST_STATE);
- }, [client]);
-
- // Time formatting
- const currentTime = formatTime(playerState.progress);
- const remainingTime = formatTime(playerState.duration - playerState.progress);
- const endingTime = calculateEndingTime(
- playerState.progress,
- playerState.duration,
- );
-
- // Next episode countdown
- const showNextEpisodeCountdown = shouldShowNextEpisodeCountdown(
- playerState.duration - playerState.progress,
- !!nextItem,
- CHROMECAST_CONSTANTS.NEXT_EPISODE_COUNTDOWN_START,
- );
-
- // Cleanup
- useEffect(() => {
- return () => {
- if (controlsTimeoutRef.current) {
- clearTimeout(controlsTimeoutRef.current);
- }
- };
- }, []);
-
- return {
- // State
- playerState,
- showControls,
- currentItem,
- nextItem,
- castDevice,
- mediaStatus,
-
- // Actions
- play,
- pause,
- stop,
- togglePlay,
- seek,
- skipForward,
- skipBackward,
- disconnect,
- setShowControls: resetControlsTimeout,
-
- // Computed
- currentTime,
- remainingTime,
- endingTime,
- showNextEpisodeCountdown,
-
- // Settings
- settings,
- };
-};
diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts
index 93296ab8..e5c0c935 100644
--- a/hooks/useCasting.ts
+++ b/hooks/useCasting.ts
@@ -34,10 +34,18 @@ export const useCasting = (item: BaseItemDto | null) => {
const [state, setState] = useState(DEFAULT_CAST_STATE);
const progressIntervalRef = useRef(null);
const controlsTimeoutRef = useRef(null);
+ const lastReportedProgressRef = useRef(0);
+ const volumeDebounceRef = useRef(null);
// Detect which protocol is active
const chromecastConnected = castDevice !== null;
- const airplayConnected = false; // TODO: Detect AirPlay connection from video player
+ // TODO: AirPlay detection requires integration with video player's AVRoutePickerView
+ // The @douglowder/expo-av-route-picker-view package doesn't expose route state
+ // Options:
+ // 1. Create native module to detect AVAudioSession.sharedInstance().currentRoute
+ // 2. Use AVPlayer's isExternalPlaybackActive property
+ // 3. Listen to AVPlayerItemDidPlayToEndTimeNotification for AirPlay events
+ const airplayConnected = false;
const activeProtocol: CastProtocol | null = chromecastConnected
? "chromecast"
@@ -94,12 +102,19 @@ export const useCasting = (item: BaseItemDto | null) => {
}
}, [mediaStatus, activeProtocol]);
- // Progress reporting to Jellyfin
+ // Progress reporting to Jellyfin (optimized to skip redundant reports)
useEffect(() => {
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
const reportProgress = () => {
const progressSeconds = Math.floor(state.progress / 1000);
+
+ // Skip if progress hasn't changed significantly (less than 5 seconds)
+ if (Math.abs(progressSeconds - lastReportedProgressRef.current) < 5) {
+ return;
+ }
+
+ lastReportedProgressRef.current = progressSeconds;
const playStateApi = api ? getPlaystateApi(api) : null;
playStateApi
?.reportPlaybackProgress({
@@ -197,15 +212,25 @@ export const useCasting = (item: BaseItemDto | null) => {
setState(DEFAULT_CAST_STATE);
}, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]);
- // Volume control
+ // Volume control (debounced to reduce API calls)
const setVolume = useCallback(
- async (volume: number) => {
+ (volume: number) => {
const clampedVolume = Math.max(0, Math.min(1, volume));
- if (activeProtocol === "chromecast") {
- await client?.setStreamVolume(clampedVolume);
- }
- // TODO: AirPlay volume control
+
+ // Update UI immediately
setState((prev) => ({ ...prev, volume: clampedVolume }));
+
+ // Debounce API call
+ if (volumeDebounceRef.current) {
+ clearTimeout(volumeDebounceRef.current);
+ }
+
+ volumeDebounceRef.current = setTimeout(async () => {
+ if (activeProtocol === "chromecast") {
+ await client?.setStreamVolume(clampedVolume).catch(console.error);
+ }
+ // TODO: AirPlay volume control
+ }, 300);
},
[client, activeProtocol],
);
@@ -240,6 +265,9 @@ export const useCasting = (item: BaseItemDto | null) => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
+ if (volumeDebounceRef.current) {
+ clearTimeout(volumeDebounceRef.current);
+ }
};
}, []);