diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx
index dee974779..1e6572f08 100644
--- a/app/(auth)/casting-player.tsx
+++ b/app/(auth)/casting-player.tsx
@@ -6,19 +6,11 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
-import { Image } from "expo-image";
import { router, Stack } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- ActivityIndicator,
- Dimensions,
- Pressable,
- ScrollView,
- View,
-} from "react-native";
-import { Slider } from "react-native-awesome-slider";
+import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import GoogleCast, {
CastState,
@@ -39,6 +31,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
+import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
@@ -52,15 +45,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { detectCapabilities } from "@/utils/casting/capabilities";
import { loadCastMedia } from "@/utils/casting/castLoad";
-import {
- calculateEndingTime,
- formatTime,
- formatTrickplayTime,
- getPosterUrl,
-} from "@/utils/casting/helpers";
+import { getPosterUrl } from "@/utils/casting/helpers";
import { resolveSelection } from "@/utils/casting/selection";
import type { CastSelection } from "@/utils/casting/types";
-import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function CastingPlayerScreen() {
const insets = useSafeAreaInsets();
@@ -735,209 +722,25 @@ export default function CastingPlayerScreen() {
zIndex: 98,
}}
>
- {/* Progress slider with trickplay preview */}
-
- {
- isScrubbing.current = true;
- }}
- onValueChange={(value) => {
- // Calculate trickplay preview
- const progressInTicks = msToTicks(value);
- calculateTrickplayUrl(progressInTicks);
-
- // Update time display for trickplay bubble
- const progressInSeconds = Math.floor(
- ticksToSeconds(progressInTicks),
- );
- const hours = Math.floor(progressInSeconds / 3600);
- const minutes = Math.floor((progressInSeconds % 3600) / 60);
- const seconds = progressInSeconds % 60;
- setTrickplayTime({ hours, minutes, seconds });
-
- // Track scrub percentage for bubble positioning
- const durationMs = duration * 1000;
- if (durationMs > 0) {
- setScrubPercentage(value / durationMs);
- }
- }}
- onSlidingComplete={(value) => {
- isScrubbing.current = false;
- // Seek to the position (value is in milliseconds, convert to seconds)
- const positionSeconds = value / 1000;
- if (remoteMediaClient && duration > 0) {
- remoteMediaClient
- .seek({ position: positionSeconds })
- .catch((error) => {
- console.error("[Casting Player] Seek error:", error);
- });
- }
- }}
- renderBubble={() => {
- // Calculate bubble position with edge clamping
- const screenWidth = Dimensions.get("window").width;
- const containerPadding = 20; // left/right padding of slider container (matches style)
- const thumbWidth = 16; // matches thumbWidth prop on Slider
- const sliderWidth = screenWidth - containerPadding * 2;
- // Adjust thumb position to account for thumb width affecting travel range
- const effectiveTrackWidth = sliderWidth - thumbWidth;
- const thumbPosition =
- thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
-
- if (!trickPlayUrl || !trickplayInfo) {
- // Show simple time bubble when no trickplay
- const timeBubbleWidth = 80;
- // Clamp position so bubble stays on screen
- // minLeft prevents going off left edge, maxLeft prevents going off right edge
- const minLeft = -thumbPosition;
- const maxLeft =
- sliderWidth - thumbPosition - timeBubbleWidth;
- const centeredLeft = -timeBubbleWidth / 2;
- const clampedLeft = Math.max(
- minLeft,
- Math.min(maxLeft, centeredLeft),
- );
-
- return (
-
-
- {formatTrickplayTime(trickplayTime)}
-
-
- );
- }
-
- const { x, y, url } = trickPlayUrl;
- const tileWidth = 220; // Larger preview for casting player
- const tileHeight =
- tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
-
- // Calculate clamped position for trickplay preview
- // minLeft: furthest left (when thumb is at left edge)
- // maxLeft: furthest right (when thumb is at right edge)
- const minLeft = -thumbPosition;
- const maxLeft = sliderWidth - thumbPosition - tileWidth;
- const centeredLeft = -tileWidth / 2;
- const clampedLeft = Math.max(
- minLeft,
- Math.min(maxLeft, centeredLeft),
- );
-
- return (
-
- {/* Trickplay image preview */}
-
-
-
- {/* Time overlay */}
-
-
- {formatTrickplayTime(trickplayTime)}
-
-
-
- );
- }}
- sliderHeight={6}
- thumbWidth={16}
- panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }}
- />
-
-
- {/* Time display */}
-
-
- {formatTime(progress * 1000)}
-
-
- {t("casting_player.ending_at", {
- time: calculateEndingTime(progress * 1000, duration * 1000),
- })}
-
-
- {formatTime(duration * 1000)}
-
-
+ {/* Progress slider with trickplay preview + time display */}
+
{/* Playback controls */}
;
+
+interface CastPlayerProgressBarProps {
+ /** Shared value tracking the slider progress, in milliseconds. */
+ sliderProgress: SharedValue;
+ /** Shared value for the slider minimum, in milliseconds. */
+ sliderMin: SharedValue;
+ /** Shared value for the slider maximum, in milliseconds. */
+ sliderMax: SharedValue;
+ /** Mutable ref flag set true while the user is scrubbing. */
+ isScrubbing: { current: boolean };
+ /** Current scrub percentage (0-1), used to position the trickplay bubble. */
+ scrubPercentage: number;
+ /** Updates the scrub percentage. */
+ setScrubPercentage: (value: number) => void;
+ /** Trickplay time display state for the bubble. */
+ trickplayTime: { hours: number; minutes: number; seconds: number };
+ /** Updates the trickplay time display state. */
+ setTrickplayTime: (time: {
+ hours: number;
+ minutes: number;
+ seconds: number;
+ }) => void;
+ /** Current trickplay image URL/coordinates, or null. */
+ trickPlayUrl: TrickplayReturn["trickPlayUrl"];
+ /** Computes the trickplay URL for a given progress in ticks. */
+ calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
+ /** Parsed trickplay metadata, or null. */
+ trickplayInfo: TrickplayReturn["trickplayInfo"];
+ /** Current playback progress, in seconds. */
+ progress: number;
+ /** Total media duration, in seconds. */
+ duration: number;
+ /** Remote media client, or null when no session. */
+ remoteMediaClient: RemoteMediaClient | null;
+ /** Theme color used for the slider track and bubbles. */
+ protocolColor: string;
+ /** Translation function. */
+ t: TFunction;
+}
+
+export function CastPlayerProgressBar({
+ sliderProgress,
+ sliderMin,
+ sliderMax,
+ isScrubbing,
+ scrubPercentage,
+ setScrubPercentage,
+ trickplayTime,
+ setTrickplayTime,
+ trickPlayUrl,
+ calculateTrickplayUrl,
+ trickplayInfo,
+ progress,
+ duration,
+ remoteMediaClient,
+ protocolColor,
+ t,
+}: CastPlayerProgressBarProps) {
+ return (
+ <>
+ {/* Progress slider with trickplay preview */}
+
+ {
+ isScrubbing.current = true;
+ }}
+ onValueChange={(value) => {
+ // Calculate trickplay preview
+ const progressInTicks = msToTicks(value);
+ calculateTrickplayUrl(progressInTicks);
+
+ // Update time display for trickplay bubble
+ const progressInSeconds = Math.floor(
+ ticksToSeconds(progressInTicks),
+ );
+ const hours = Math.floor(progressInSeconds / 3600);
+ const minutes = Math.floor((progressInSeconds % 3600) / 60);
+ const seconds = progressInSeconds % 60;
+ setTrickplayTime({ hours, minutes, seconds });
+
+ // Track scrub percentage for bubble positioning
+ const durationMs = duration * 1000;
+ if (durationMs > 0) {
+ setScrubPercentage(value / durationMs);
+ }
+ }}
+ onSlidingComplete={(value) => {
+ isScrubbing.current = false;
+ // Seek to the position (value is in milliseconds, convert to seconds)
+ const positionSeconds = value / 1000;
+ if (remoteMediaClient && duration > 0) {
+ remoteMediaClient
+ .seek({ position: positionSeconds })
+ .catch((error) => {
+ console.error("[Casting Player] Seek error:", error);
+ });
+ }
+ }}
+ renderBubble={() => {
+ // Calculate bubble position with edge clamping
+ const screenWidth = Dimensions.get("window").width;
+ const containerPadding = 20; // left/right padding of slider container (matches style)
+ const thumbWidth = 16; // matches thumbWidth prop on Slider
+ const sliderWidth = screenWidth - containerPadding * 2;
+ // Adjust thumb position to account for thumb width affecting travel range
+ const effectiveTrackWidth = sliderWidth - thumbWidth;
+ const thumbPosition =
+ thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
+
+ if (!trickPlayUrl || !trickplayInfo) {
+ // Show simple time bubble when no trickplay
+ const timeBubbleWidth = 80;
+ // Clamp position so bubble stays on screen
+ // minLeft prevents going off left edge, maxLeft prevents going off right edge
+ const minLeft = -thumbPosition;
+ const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
+ const centeredLeft = -timeBubbleWidth / 2;
+ const clampedLeft = Math.max(
+ minLeft,
+ Math.min(maxLeft, centeredLeft),
+ );
+
+ return (
+
+
+ {formatTrickplayTime(trickplayTime)}
+
+
+ );
+ }
+
+ const { x, y, url } = trickPlayUrl;
+ const tileWidth = 220; // Larger preview for casting player
+ const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
+
+ // Calculate clamped position for trickplay preview
+ // minLeft: furthest left (when thumb is at left edge)
+ // maxLeft: furthest right (when thumb is at right edge)
+ const minLeft = -thumbPosition;
+ const maxLeft = sliderWidth - thumbPosition - tileWidth;
+ const centeredLeft = -tileWidth / 2;
+ const clampedLeft = Math.max(
+ minLeft,
+ Math.min(maxLeft, centeredLeft),
+ );
+
+ return (
+
+ {/* Trickplay image preview */}
+
+
+
+ {/* Time overlay */}
+
+
+ {formatTrickplayTime(trickplayTime)}
+
+
+
+ );
+ }}
+ sliderHeight={6}
+ thumbWidth={16}
+ panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }}
+ />
+
+
+ {/* Time display */}
+
+
+ {formatTime(progress * 1000)}
+
+
+ {t("casting_player.ending_at", {
+ time: calculateEndingTime(progress * 1000, duration * 1000),
+ })}
+
+
+ {formatTime(duration * 1000)}
+
+
+ >
+ );
+}