From 0d922b75d6a2f98dc0254b4b2cdfb9e0cc7a242c Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 22 May 2026 01:08:04 +0200 Subject: [PATCH] refactor(casting): extract CastPlayerProgressBar --- app/(auth)/casting-player.tsx | 241 ++------------- .../casting/player/CastPlayerProgressBar.tsx | 282 ++++++++++++++++++ 2 files changed, 304 insertions(+), 219 deletions(-) create mode 100644 components/casting/player/CastPlayerProgressBar.tsx 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)} + + + + ); +}