From bcf6b705e17d701a0be5e67ed3c6471532b7b736 Mon Sep 17 00:00:00 2001 From: Uruk Date: Thu, 21 May 2026 02:27:20 +0200 Subject: [PATCH] refactor(casting): route all cast loads through loadCastMedia Co-Authored-By: Claude Opus 4.7 --- app/(auth)/casting-player.tsx | 90 ++++++++++++-------------- components/PlayButton.tsx | 104 ++++++++++--------------------- hooks/useCasting.ts | 21 ++++--- utils/profiles/chromecast.ts | 96 ---------------------------- utils/profiles/chromecasth265.ts | 95 ---------------------------- 5 files changed, 85 insertions(+), 321 deletions(-) delete mode 100644 utils/profiles/chromecast.ts delete mode 100644 utils/profiles/chromecasth265.ts diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 0234f5a29..71636b174 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -44,6 +44,7 @@ import { useCasting } from "@/hooks/useCasting"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { loadCastMedia } from "@/utils/casting/castLoad"; import { calculateEndingTime, formatTime, @@ -51,10 +52,6 @@ import { getPosterUrl, truncateTitle, } from "@/utils/casting/helpers"; -import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { msToTicks, ticksToSeconds } from "@/utils/time"; export default function CastingPlayerScreen() { @@ -292,15 +289,8 @@ export default function CastingPlayerScreen() { } try { - // Save current playback position const currentPosition = mediaStatus?.streamPosition ?? 0; - // Get new stream URL with updated settings - const enableH265 = settings.enableH265ForChromecast; - - // Resolve subtitle index: - // - options.subtitleIndex is explicitly provided: null = disable (-1), number = specific track - // - options.subtitleIndex is undefined: preserve current selection let resolvedSubtitleIndex: number | undefined; if (options.subtitleIndex === undefined) { resolvedSubtitleIndex = selectedSubtitleTrackIndex ?? undefined; @@ -310,32 +300,29 @@ export default function CastingPlayerScreen() { resolvedSubtitleIndex = options.subtitleIndex; } - const data = await getStreamUrl({ + const result = await loadCastMedia({ + client: remoteMediaClient, + device: castDevice, api, item: currentItem, - deviceProfile: enableH265 ? chromecasth265 : chromecast, - startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks userId: user.Id, - audioStreamIndex: - options.audioIndex ?? selectedAudioTrackIndex ?? undefined, - subtitleStreamIndex: resolvedSubtitleIndex, - maxStreamingBitrate: options.bitrateValue, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { + audioStreamIndex: + options.audioIndex ?? selectedAudioTrackIndex ?? undefined, + subtitleStreamIndex: resolvedSubtitleIndex, + maxBitrate: options.bitrateValue, + startPositionMs: currentPosition * 1000, + }, }); - if (!data?.url) { - console.error("[Casting Player] Failed to get stream URL"); - return; + if (!result.ok) { + console.error( + "[Casting Player] Failed to reload stream:", + result.error, + ); } - - // Reload media with new URL - await remoteMediaClient.loadMedia({ - mediaInfo: buildCastMediaInfo({ - item: currentItem, - streamUrl: data.url, - api, - }), - startTime: currentPosition, // Resume at same position - }); } catch (error) { console.error("[Casting Player] Failed to reload stream:", error); } @@ -345,8 +332,10 @@ export default function CastingPlayerScreen() { user?.Id, currentItem, remoteMediaClient, + castDevice, mediaStatus?.streamPosition, - settings.enableH265ForChromecast, + settings.chromecastProfile, + settings.chromecastMaxBitrate, selectedAudioTrackIndex, selectedSubtitleTrackIndex, ], @@ -358,39 +347,42 @@ export default function CastingPlayerScreen() { if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return; try { - const enableH265 = settings.enableH265ForChromecast; - const data = await getStreamUrl({ + const startPositionMs = + (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000; + + const result = await loadCastMedia({ + client: remoteMediaClient, + device: castDevice, api, item: episode, - deviceProfile: enableH265 ? chromecasth265 : chromecast, - startTimeTicks: episode.UserData?.PlaybackPositionTicks ?? 0, userId: user.Id, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { startPositionMs }, }); - if (!data?.url) { + if (!result.ok) { console.error( - "[Casting Player] Failed to get stream URL for episode", + "[Casting Player] Failed to load episode:", + result.error, ); return; } - await remoteMediaClient.loadMedia({ - mediaInfo: buildCastMediaInfo({ - item: episode, - streamUrl: data.url, - api, - }), - startTime: (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000000, - }); - - // Reset track selections for new episode setSelectedAudioTrackIndex(null); setSelectedSubtitleTrackIndex(null); } catch (error) { console.error("[Casting Player] Failed to load episode:", error); } }, - [api, user?.Id, remoteMediaClient, settings.enableH265ForChromecast], + [ + api, + user?.Id, + remoteMediaClient, + castDevice, + settings.chromecastProfile, + settings.chromecastMaxBitrate, + ], ); // Fetch season data for season poster diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index def898c7e..750cc13c0 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -10,6 +10,7 @@ import CastContext, { CastButton, MediaPlayerState, PlayServicesState, + useCastDevice, useMediaStatus, useRemoteMediaClient, } from "react-native-google-cast"; @@ -32,10 +33,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; -import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; +import { loadCastMedia } from "@/utils/casting/castLoad"; import { runtimeTicksToMinutes } from "@/utils/time"; import { Button } from "./Button"; import { Text } from "./common/Text"; @@ -58,6 +56,7 @@ export const PlayButton: React.FC = ({ const isOffline = useOfflineMode(); const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); + const castDevice = useCastDevice(); const mediaStatus = useMediaStatus(); const { t } = useTranslation(); const { showModal, hideModal } = useGlobalModal(); @@ -138,30 +137,8 @@ export const PlayButton: React.FC = ({ if (state && state !== PlayServicesState.SUCCESS) { CastContext.showPlayServicesErrorDialog(state); } else { - // Check if user wants H265 for Chromecast - const enableH265 = settings.enableH265ForChromecast; - - // Validate required parameters before calling getStreamUrl - if (!api) { - console.warn("API not available for Chromecast streaming"); - Alert.alert( - t("player.client_error"), - t("player.missing_parameters"), - ); - return; - } - if (!user?.Id) { - console.warn( - "User not authenticated for Chromecast streaming", - ); - Alert.alert( - t("player.client_error"), - t("player.missing_parameters"), - ); - return; - } - if (!item?.Id) { - console.warn("Item not available for Chromecast streaming"); + if (!api || !user?.Id || !item?.Id) { + console.warn("Missing parameters for Chromecast streaming"); Alert.alert( t("player.client_error"), t("player.missing_parameters"), @@ -169,53 +146,37 @@ export const PlayButton: React.FC = ({ return; } - // Get a new URL with the Chromecast device profile - try { - const data = await getStreamUrl({ - api, - item, - deviceProfile: enableH265 ? chromecasth265 : chromecast, - startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0, - userId: user.Id, + const startPositionMs = + (item.UserData?.PlaybackPositionTicks ?? 0) / 10000; + + const result = await loadCastMedia({ + client, + device: castDevice, + api, + item, + userId: user.Id, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { audioStreamIndex: selectedOptions.audioIndex, - maxStreamingBitrate: selectedOptions.bitrate?.value, - mediaSourceId: selectedOptions.mediaSource?.Id, subtitleStreamIndex: selectedOptions.subtitleIndex, - }); + maxBitrate: selectedOptions.bitrate?.value, + mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined, + startPositionMs, + }, + }); - if (!data?.url) { - console.warn("No URL returned from getStreamUrl", data); - Alert.alert( - t("player.client_error"), - t("player.could_not_create_stream_for_chromecast"), - ); - return; - } + if (!result.ok) { + console.error("[PlayButton] cast load failed:", result.error); + Alert.alert( + t("player.client_error"), + t("player.could_not_create_stream_for_chromecast"), + ); + return; + } - const startTimeSeconds = - (item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000; - - client - .loadMedia({ - mediaInfo: buildCastMediaInfo({ - item, - streamUrl: data.url, - api, - }), - startTime: startTimeSeconds, - }) - .then(() => { - // state is already set when reopening current media, so skip it here. - if (isOpeningCurrentlyPlayingMedia) { - return; - } - router.push("/casting-player"); - }) - .catch((err) => { - console.error("[PlayButton] loadMedia failed:", err); - }); - } catch (e) { - console.error("[PlayButton] Cast error:", e); + if (!isOpeningCurrentlyPlayingMedia) { + router.push("/casting-player"); } } }); @@ -231,6 +192,7 @@ export const PlayButton: React.FC = ({ }, [ item, client, + castDevice, settings, api, user, diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index 715e7d5d7..cfaecdee2 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -52,6 +52,14 @@ export const useCasting = (item: BaseItemDto | null) => { [], ); + // Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo. + const playSessionId = + ( + mediaStatus?.mediaInfo?.customData as + | { playSessionId?: string } + | undefined + )?.playSessionId ?? mediaStatus?.mediaInfo?.contentId; + // Detect which protocol is active - use CastState for reliable detection const chromecastConnected = castState === CastState.CONNECTED; // Future: Add detection for other protocols here @@ -139,7 +147,7 @@ export const useCasting = (item: BaseItemDto | null) => { activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", VolumeLevel: Math.floor(currentState.volume * 100), IsMuted: currentState.volume === 0, - PlaySessionId: mediaStatus?.mediaInfo?.contentId, + PlaySessionId: playSessionId, }, }) .catch((error) => { @@ -179,7 +187,7 @@ export const useCasting = (item: BaseItemDto | null) => { activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", VolumeLevel: Math.floor(s.volume * 100), IsMuted: s.volume === 0, - PlaySessionId: mediaStatus?.mediaInfo?.contentId, + PlaySessionId: playSessionId, }, }) .catch((error) => { @@ -190,14 +198,7 @@ export const useCasting = (item: BaseItemDto | null) => { // Report progress on a fixed interval, reading latest state from ref const interval = setInterval(reportProgress, 10000); return () => clearInterval(interval); - }, [ - api, - item?.Id, - user?.Id, - isConnected, - activeProtocol, - mediaStatus?.mediaInfo?.contentId, - ]); + }, [api, item?.Id, user?.Id, isConnected, activeProtocol, playSessionId]); // Play/Pause controls const play = useCallback(async () => { diff --git a/utils/profiles/chromecast.ts b/utils/profiles/chromecast.ts deleted file mode 100644 index c09f4111a..000000000 --- a/utils/profiles/chromecast.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models"; - -export const chromecast: DeviceProfile = { - Name: "Chromecast Video Profile", - MaxStreamingBitrate: 16000000, // 16 Mbps - MaxStaticBitrate: 16000000, // 16 Mbps - MusicStreamingTranscodingBitrate: 384000, // 384 kbps - CodecProfiles: [ - { - Type: "Video", - Codec: "h264", - }, - { - Type: "Audio", - Codec: "aac,mp3,flac,opus,vorbis", - // Force transcode if audio has more than 2 channels (5.1, 7.1, etc) - Conditions: [ - { - Condition: "LessThanEqual", - Property: "AudioChannels", - Value: "2", - }, - ], - }, - ], - ContainerProfiles: [], - DirectPlayProfiles: [ - { - Container: "mp4", - Type: "Video", - VideoCodec: "h264", - AudioCodec: "aac,mp3,opus,vorbis", - }, - { - Container: "mp3", - Type: "Audio", - }, - { - Container: "aac", - Type: "Audio", - }, - { - Container: "flac", - Type: "Audio", - }, - { - Container: "wav", - Type: "Audio", - }, - ], - TranscodingProfiles: [ - { - Container: "ts", - Type: "Video", - VideoCodec: "h264", - AudioCodec: "aac,mp3", - Protocol: "hls", - Context: "Streaming", - MaxAudioChannels: "2", - MinSegments: 2, - BreakOnNonKeyFrames: true, - }, - { - Container: "mp4", - Type: "Video", - VideoCodec: "h264", - AudioCodec: "aac", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - MinSegments: 2, - }, - { - Container: "mp3", - Type: "Audio", - AudioCodec: "mp3", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - }, - { - Container: "aac", - Type: "Audio", - AudioCodec: "aac", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - }, - ], - SubtitleProfiles: [ - { - Format: "vtt", - Method: "Encode", - }, - ], -}; diff --git a/utils/profiles/chromecasth265.ts b/utils/profiles/chromecasth265.ts deleted file mode 100644 index 6e5f9fb67..000000000 --- a/utils/profiles/chromecasth265.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models"; - -export const chromecasth265: DeviceProfile = { - Name: "Chromecast Video Profile", - MaxStreamingBitrate: 16000000, // 16Mbps - MaxStaticBitrate: 16000000, // 16 Mbps - MusicStreamingTranscodingBitrate: 384000, // 384 kbps - CodecProfiles: [ - { - Type: "Video", - Codec: "hevc,h264", - }, - { - Type: "Audio", - Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc) - Conditions: [ - { - Condition: "LessThanEqual", - Property: "AudioChannels", - Value: "2", - }, - ], - }, - ], - ContainerProfiles: [], - DirectPlayProfiles: [ - { - Container: "mp4,mkv", - Type: "Video", - VideoCodec: "hevc,h264", - AudioCodec: "aac,mp3,opus,vorbis", - }, - { - Container: "mp3", - Type: "Audio", - }, - { - Container: "aac", - Type: "Audio", - }, - { - Container: "flac", - Type: "Audio", - }, - { - Container: "wav", - Type: "Audio", - }, - ], - TranscodingProfiles: [ - { - Container: "ts", - Type: "Video", - VideoCodec: "hevc,h264", - AudioCodec: "aac,mp3", - Protocol: "hls", - Context: "Streaming", - MaxAudioChannels: "2", - MinSegments: 2, - BreakOnNonKeyFrames: true, - }, - { - Container: "mp4,mkv", - Type: "Video", - VideoCodec: "hevc,h264", - AudioCodec: "aac", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - MinSegments: 2, - }, - { - Container: "mp3", - Type: "Audio", - AudioCodec: "mp3", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - }, - { - Container: "aac", - Type: "Audio", - AudioCodec: "aac", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: "2", - }, - ], - SubtitleProfiles: [ - { - Format: "vtt", - Method: "Encode", - }, - ], -};