fix: Refactors casting player and components

Refactors the casting player screen and related components for improved code clarity, performance, and maintainability.

- Removes unused code and simplifies logic, especially around audio track selection and recommended stereo track handling.
- Improves the formatting of trickplay time displays for consistency.
- Streamlines UI elements and removes unnecessary conditional checks.
- Updates the Chromecast component to use hooks for side effects, ensuring the Chromecast session remains active.
- Improves the display of the language in the audio track display.
This commit is contained in:
Uruk
2026-02-09 22:03:48 +01:00
parent 2c27186e22
commit f34997a024
7 changed files with 50 additions and 290 deletions

View File

@@ -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}`,
)}
</Text>
</Pressable>
)}
@@ -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)}
</Text>
</View>
);
@@ -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)}
</Text>
</View>
</View>
@@ -1319,7 +1275,7 @@ export default function CastingPlayerScreen() {
color='white'
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
/>
{settings?.rewindSkipTime && (
{!!settings?.rewindSkipTime && (
<Text
style={{
position: "absolute",
@@ -1364,7 +1320,7 @@ export default function CastingPlayerScreen() {
}}
>
<Ionicons name='refresh-outline' size={48} color='white' />
{settings?.forwardSkipTime && (
{!!settings?.forwardSkipTime && (
<Text
style={{
position: "absolute",
@@ -1383,10 +1339,10 @@ export default function CastingPlayerScreen() {
{/* Modals */}
<ChromecastDeviceSheet
visible={showDeviceSheet && protocol === "chromecast"}
visible={showDeviceSheet}
onClose={() => 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);

View File

@@ -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<string | null>(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;
}

View File

@@ -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 = () => {
<Text
style={{ color: "#fff", fontSize: 11, 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)}
</Text>
</View>
);
@@ -347,11 +348,7 @@ export const CastingMiniPlayer: React.FC = () => {
<Text
style={{ color: "#fff", fontSize: 10, 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)}
</Text>
</View>
</View>

View File

@@ -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<CastPlayerState>(DEFAULT_CAST_STATE);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastReportedProgressRef = useRef(0);
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const hasReportedStartRef = useRef<string | null>(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,
};
};

View File

@@ -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}`;
};

View File

@@ -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";

View File

@@ -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",
};