diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 0d9ca9ef..75c06509 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -47,11 +47,11 @@ import { useSettings } from "@/utils/atoms/settings"; import { calculateEndingTime, formatTime, + formatTrickplayTime, getPosterUrl, truncateTitle, } from "@/utils/casting/helpers"; import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; -import type { CastProtocol } from "@/utils/casting/types"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { chromecast } from "@/utils/profiles/chromecast"; import { chromecasth265 } from "@/utils/profiles/chromecasth265"; @@ -180,7 +180,6 @@ export default function CastingPlayerScreen() { }, [fetchedItem, mediaStatus?.mediaInfo]); // Derive state from raw Chromecast hooks - const protocol: CastProtocol = "chromecast"; const progress = liveProgress; // Use live-updating progress const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0; const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING; @@ -241,7 +240,7 @@ export default function CastingPlayerScreen() { const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState< number | null >(null); - const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); + const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1); // Function to reload media with new audio/subtitle/quality settings const reloadWithSettings = useCallback( @@ -399,7 +398,11 @@ export default function CastingPlayerScreen() { language: stream.Language || "Unknown", displayTitle: stream.DisplayTitle || - `${stream.Language || "Unknown"}${stream.IsForced ? " (Forced)" : ""}${stream.Title ? ` - ${stream.Title}` : ""}`, + [ + stream.Language || "Unknown", + stream.IsForced ? " (Forced)" : "", + stream.Title ? ` - ${stream.Title}` : "", + ].join(""), codec: stream.Codec || "Unknown", isForced: stream.IsForced || false, isExternal: stream.IsExternal || false, @@ -450,43 +453,6 @@ export default function CastingPlayerScreen() { return variants; }, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]); - // Track whether user has manually selected an audio track - const [userSelectedAudio, setUserSelectedAudio] = useState(false); - - // Detect recommended stereo track for Chromecast compatibility. - // Does NOT mutate selectedAudioTrackIndex — UI can show a badge instead. - // TODO: Use recommendedAudioTrackIndex in UI to show a "stereo recommended" badge - const [_recommendedAudioTrackIndex, setRecommendedAudioTrackIndex] = useState< - number | null - >(null); - - useEffect(() => { - if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio) { - setRecommendedAudioTrackIndex(null); - return; - } - - const currentTrack = availableAudioTracks.find( - (t) => t.index === selectedAudioTrackIndex, - ); - - // If current track is 5.1+ audio, recommend stereo alternative - if (currentTrack && (currentTrack.channels || 0) > 2) { - const stereoTrack = availableAudioTracks.find((t) => t.channels === 2); - if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) { - setRecommendedAudioTrackIndex(stereoTrack.index); - return; - } - } - setRecommendedAudioTrackIndex(null); - }, [ - mediaStatus?.mediaInfo, - availableAudioTracks, - remoteMediaClient, - selectedAudioTrackIndex, - userSelectedAudio, - ]); - // Fetch episodes for TV shows useEffect(() => { if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api) @@ -532,7 +498,7 @@ export default function CastingPlayerScreen() { useEffect(() => { if (mediaStatus?.currentItemId && !currentItem) { // New media started casting while we're not on the player - router.replace("/casting-player" as "/casting-player"); + router.replace("/casting-player" as const); } }, [mediaStatus?.currentItemId, currentItem, router]); @@ -907,11 +873,9 @@ export default function CastingPlayerScreen() { fontWeight: "600", }} > - {currentSegment.type === "intro" - ? t("player.skip_intro") - : currentSegment.type === "credits" - ? t("player.skip_outro") - : `Skip ${currentSegment.type}`} + {t( + `player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`, + )} )} @@ -1174,11 +1138,7 @@ export default function CastingPlayerScreen() { fontWeight: "600", }} > - {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ - trickplayTime.minutes < 10 - ? `0${trickplayTime.minutes}` - : trickplayTime.minutes - }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + {formatTrickplayTime(trickplayTime)} ); @@ -1257,11 +1217,7 @@ export default function CastingPlayerScreen() { fontWeight: "600", }} > - {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ - trickplayTime.minutes < 10 - ? `0${trickplayTime.minutes}` - : trickplayTime.minutes - }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + {formatTrickplayTime(trickplayTime)} @@ -1319,7 +1275,7 @@ export default function CastingPlayerScreen() { color='white' style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }} /> - {settings?.rewindSkipTime && ( + {!!settings?.rewindSkipTime && ( - {settings?.forwardSkipTime && ( + {!!settings?.forwardSkipTime && ( setShowDeviceSheet(false)} device={ - currentDevice && protocol === "chromecast" && castDevice + currentDevice && castDevice ? { friendlyName: currentDevice } : null } @@ -1414,7 +1370,7 @@ export default function CastingPlayerScreen() { volume={volume} onVolumeChange={async (vol) => { try { - await setVolume(vol); + setVolume(vol); } catch (error) { console.error("[Casting Player] Failed to set volume:", error); } @@ -1448,25 +1404,24 @@ export default function CastingPlayerScreen() { }} audioTracks={availableAudioTracks} selectedAudioTrack={ - selectedAudioTrackIndex !== null - ? availableAudioTracks.find( + selectedAudioTrackIndex === null + ? availableAudioTracks[0] || null + : availableAudioTracks.find( (t) => t.index === selectedAudioTrackIndex, ) || null - : availableAudioTracks[0] || null } onAudioTrackChange={(track) => { - setUserSelectedAudio(true); setSelectedAudioTrackIndex(track.index); // Reload stream with new audio track reloadWithSettings({ audioIndex: track.index }); }} subtitleTracks={availableSubtitleTracks} selectedSubtitleTrack={ - selectedSubtitleTrackIndex !== null - ? availableSubtitleTracks.find( + selectedSubtitleTrackIndex === null + ? null + : availableSubtitleTracks.find( (t) => t.index === selectedSubtitleTrackIndex, ) || null - : null } onSubtitleTrackChange={(track) => { setSelectedSubtitleTrackIndex(track?.index ?? null); diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 10383744..7a5e90a9 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -26,11 +26,11 @@ export function Chromecast({ background = "transparent", ...props }) { - const _client = useRemoteMediaClient(); - const _castDevice = useCastDevice(); + // Hooks called for their side effects (keep Chromecast session active) + useRemoteMediaClient(); + useCastDevice(); const castState = useCastState(); - const devices = useDevices(); - const _sessionManager = GoogleCast.getSessionManager(); + useDevices(); const discoveryManager = GoogleCast.getDiscoveryManager(); const mediaStatus = useMediaStatus(); const api = useAtomValue(apiAtom); @@ -46,7 +46,6 @@ export function Chromecast({ const lastContentIdRef = useRef(null); const discoveryAttempts = useRef(0); const maxDiscoveryAttempts = 3; - const hasLoggedDevices = useRef(false); // Enhanced discovery with retry mechanism - runs once on mount useEffect(() => { @@ -62,7 +61,7 @@ export function Chromecast({ // Stop any existing discovery first try { await discoveryManager.stopDiscovery(); - } catch (_e) { + } catch { // Ignore errors when stopping } @@ -94,25 +93,9 @@ export function Chromecast({ }; }, [discoveryManager]); // Only re-run if discoveryManager changes - // Log device changes for debugging - only once per session - useEffect(() => { - if (devices.length > 0 && !hasLoggedDevices.current) { - console.log( - "[Chromecast] Found device(s):", - devices.map((d) => d.friendlyName || d.deviceId).join(", "), - ); - hasLoggedDevices.current = true; - } - }, [devices]); - // Report video progress to Jellyfin server useEffect(() => { - if ( - !api || - !user?.Id || - !mediaStatus || - !mediaStatus.mediaInfo?.contentId - ) { + if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) { return; } diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 9d53c34f..7797d4b1 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -26,7 +26,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { formatTime, getPosterUrl } from "@/utils/casting/helpers"; +import { + formatTime, + formatTrickplayTime, + getPosterUrl, +} from "@/utils/casting/helpers"; import { CASTING_CONSTANTS } from "@/utils/casting/types"; import { msToTicks, ticksToSeconds } from "@/utils/time"; @@ -129,7 +133,8 @@ export const CastingMiniPlayer: React.FC = () => { ) { // Build season poster URL using SeriesId and image tag for cache validation const imageTag = currentItem.ImageTags?.Primary || ""; - return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${imageTag ? `&tag=${imageTag}` : ""}`; + const tagParam = imageTag ? `&tag=${imageTag}` : ""; + return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`; } // For non-episodes, use item's own poster @@ -273,11 +278,7 @@ export const CastingMiniPlayer: React.FC = () => { - {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ - trickplayTime.minutes < 10 - ? `0${trickplayTime.minutes}` - : trickplayTime.minutes - }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + {formatTrickplayTime(trickplayTime)} ); @@ -347,11 +348,7 @@ export const CastingMiniPlayer: React.FC = () => { - {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ - trickplayTime.minutes < 10 - ? `0${trickplayTime.minutes}` - : trickplayTime.minutes - }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + {formatTrickplayTime(trickplayTime)} diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index 7a55605f..d7ed2791 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -26,7 +26,6 @@ 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(); // TODO: Use for preferences // Chromecast hooks const client = useRemoteMediaClient(); @@ -36,7 +35,6 @@ export const useCasting = (item: BaseItemDto | null) => { // Local state const [state, setState] = useState(DEFAULT_CAST_STATE); - const controlsTimeoutRef = useRef(null); const lastReportedProgressRef = useRef(0); const volumeDebounceRef = useRef(null); const hasReportedStartRef = useRef(null); // Track which item we reported start for @@ -347,34 +345,9 @@ export const useCasting = (item: BaseItemDto | null) => { [client, activeProtocol, isConnected], ); - // Controls visibility - const showControls = useCallback(() => { - updateState((prev) => ({ ...prev, showControls: true })); - - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - // Read latest isPlaying from stateRef to avoid stale closure - if (stateRef.current.isPlaying) { - updateState((prev) => ({ ...prev, showControls: false })); - } - }, 5000); - }, [updateState]); - - const hideControls = useCallback(() => { - updateState((prev) => ({ ...prev, showControls: false })); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }, []); - // Cleanup useEffect(() => { return () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } if (volumeDebounceRef.current) { clearTimeout(volumeDebounceRef.current); } @@ -395,7 +368,6 @@ export const useCasting = (item: BaseItemDto | null) => { // Availability isChromecastAvailable: true, // Always available via react-native-google-cast - // Future: Add availability checks for other protocols // Raw clients (for advanced operations) remoteMediaClient: client, @@ -409,7 +381,5 @@ export const useCasting = (item: BaseItemDto | null) => { skipBackward, stop, setVolume, - showControls, - hideControls, }; }; diff --git a/utils/casting/helpers.ts b/utils/casting/helpers.ts index a53c94ba..a5159de5 100644 --- a/utils/casting/helpers.ts +++ b/utils/casting/helpers.ts @@ -3,8 +3,6 @@ * Common utilities for casting protocols */ -import type { CastProtocol, ConnectionQuality } from "./types"; - /** * Format milliseconds to HH:MM:SS or MM:SS */ @@ -46,19 +44,6 @@ export const calculateEndingTime = ( } }; -/** - * Determine connection quality based on bitrate - */ -export const getConnectionQuality = (bitrate?: number): ConnectionQuality => { - if (bitrate == null) 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 */ @@ -103,78 +88,15 @@ export const isWithinSegment = ( }; /** - * Format bitrate to human-readable string + * Format trickplay time from {hours, minutes, seconds} to display string. + * Produces "H:MM:SS" when hours > 0, otherwise "MM:SS". */ -export const formatBitrate = (bitrate: number): string => { - const mbps = bitrate / 1000000; - if (mbps >= 1) { - return `${mbps.toFixed(1)} Mbps`; - } - return `${(bitrate / 1000).toFixed(0)} Kbps`; -}; - -/** - * Get protocol display name - */ -export const getProtocolName = (protocol: CastProtocol): string => { - switch (protocol) { - case "chromecast": - return "Chromecast"; - default: { - const _exhaustive: never = protocol; - return String(_exhaustive); - } - } -}; - -/** - * Get protocol icon name - */ -export const getProtocolIcon = ( - protocol: CastProtocol, -): "tv" | "logo-apple" => { - switch (protocol) { - case "chromecast": - return "tv"; - default: { - const _exhaustive: never = protocol; - return "tv"; - } - } -}; - -/** - * Format episode info (e.g., "S1 E1" or "Episode 1") - * @param seasonNumber - Season number - * @param episodeNumber - Episode number - * @param episodeLabel - Optional label for standalone episode (e.g. translated "Episode") - */ -export const formatEpisodeInfo = ( - seasonNumber?: number | null, - episodeNumber?: number | null, - episodeLabel = "Episode", -): string => { - if ( - seasonNumber !== undefined && - seasonNumber !== null && - episodeNumber !== undefined && - episodeNumber !== null - ) { - return `S${seasonNumber} E${episodeNumber}`; - } - if (episodeNumber !== undefined && episodeNumber !== null) { - return `${episodeLabel} ${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; +export const formatTrickplayTime = (time: { + hours: number; + minutes: number; + seconds: number; +}): string => { + const mm = String(time.minutes).padStart(2, "0"); + const ss = String(time.seconds).padStart(2, "0"); + return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`; }; diff --git a/utils/casting/types.ts b/utils/casting/types.ts index a6d43400..0db139fa 100644 --- a/utils/casting/types.ts +++ b/utils/casting/types.ts @@ -24,18 +24,9 @@ export interface CastPlayerState { progress: number; duration: number; volume: number; - showControls: boolean; isBuffering: boolean; } -export interface CastSegmentData { - 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; @@ -77,8 +68,5 @@ export const DEFAULT_CAST_STATE: CastPlayerState = { progress: 0, duration: 0, volume: 0.5, - showControls: true, isBuffering: false, }; - -export type ConnectionQuality = "excellent" | "good" | "fair" | "poor"; diff --git a/utils/chromecast/options.ts b/utils/chromecast/options.ts index 3c68d2e6..498a5da7 100644 --- a/utils/chromecast/options.ts +++ b/utils/chromecast/options.ts @@ -1,50 +1,7 @@ /** - * Chromecast player configuration and constants + * Chromecast player configuration and types */ -export const CHROMECAST_CONSTANTS = { - // Timing (all milliseconds for consistency) - PROGRESS_REPORT_INTERVAL_MS: 10_000, - CONTROLS_TIMEOUT_MS: 5_000, - BUFFERING_THRESHOLD_MS: 10_000, - NEXT_EPISODE_COUNTDOWN_MS: 30_000, - CONNECTION_CHECK_INTERVAL_MS: 5_000, - - // UI - POSTER_WIDTH: 300, - POSTER_HEIGHT: 450, - MINI_PLAYER_HEIGHT: 80, - SKIP_FORWARD_SECS: 15, // overridden by settings - SKIP_BACKWARD_SECS: 15, // overridden by settings - - // Animation - ANIMATION_DURATION_MS: 300, - BLUR_RADIUS: 10, -} as const; - -export const CONNECTION_QUALITY = { - EXCELLENT: { min: 50, label: "Excellent", icon: "wifi" }, // min Mbps - GOOD: { min: 30, label: "Good", icon: "signal" }, // min Mbps - FAIR: { min: 15, label: "Fair", icon: "cellular" }, // min Mbps - POOR: { min: 0, label: "Poor", icon: "warning" }, // min Mbps -} as const; - -export type ConnectionQuality = keyof typeof CONNECTION_QUALITY; - -export type PlaybackState = "playing" | "paused" | "stopped" | "buffering"; - -export interface ChromecastPlayerState { - isConnected: boolean; - deviceName: string | null; - playbackState: PlaybackState; - progress: number; // milliseconds - duration: number; // milliseconds - volume: number; // 0-1 - isMuted: boolean; - currentItemId: string | null; - connectionQuality: ConnectionQuality; -} - export interface ChromecastSegmentData { intro: { start: number; end: number } | null; credits: { start: number; end: number } | null; @@ -52,15 +9,3 @@ export interface ChromecastSegmentData { commercial: { start: number; end: number }[]; preview: { start: number; end: number }[]; } - -export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = { - isConnected: false, - deviceName: null, - playbackState: "stopped", - progress: 0, - duration: 0, - volume: 1, - isMuted: false, - currentItemId: null, - connectionQuality: "GOOD", -};