diff --git a/utils/casting/castErrors.test.ts b/utils/casting/castErrors.test.ts new file mode 100644 index 000000000..abec6f6bd --- /dev/null +++ b/utils/casting/castErrors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { isLoadFailedError } from "./castErrors"; + +describe("isLoadFailedError", () => { + test("recognises a status 2100 error message", () => { + const error = new Error( + "java.lang.Exception: Media control channel status code 2100", + ); + expect(isLoadFailedError(error)).toBe(true); + }); + + test("returns false for unrelated errors", () => { + expect(isLoadFailedError(new Error("network timeout"))).toBe(false); + }); + + test("handles non-Error values without throwing", () => { + expect(isLoadFailedError("status code 2100")).toBe(true); + expect(isLoadFailedError(null)).toBe(false); + }); +}); diff --git a/utils/casting/castErrors.ts b/utils/casting/castErrors.ts new file mode 100644 index 000000000..bd76c81c7 --- /dev/null +++ b/utils/casting/castErrors.ts @@ -0,0 +1,11 @@ +/** + * Cast load error classification. Kept dependency-free so it is unit-testable + * without pulling React Native modules into the test runtime. + */ + +/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */ +export const isLoadFailedError = (error: unknown): boolean => { + if (error == null) return false; + const message = error instanceof Error ? error.message : String(error); + return message.includes("2100"); +}; diff --git a/utils/casting/castLoad.ts b/utils/casting/castLoad.ts new file mode 100644 index 000000000..1ab612356 --- /dev/null +++ b/utils/casting/castLoad.ts @@ -0,0 +1,137 @@ +/** + * Unified Chromecast media loading. + * + * Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was + * previously duplicated across PlayButton and the casting player. Builds the + * device profile from detected capabilities and retries once with a forced + * conservative profile when the receiver rejects the initial load (status 2100). + */ + +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { RemoteMediaClient } from "react-native-google-cast"; +import { buildChromecastProfile } from "@/utils/casting/buildProfile"; +import { + type ChromecastProfileMode, + detectCapabilities, +} from "@/utils/casting/capabilities"; +import { isLoadFailedError } from "@/utils/casting/castErrors"; +import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; + +export interface CastLoadOptions { + audioStreamIndex?: number; + subtitleStreamIndex?: number; + maxBitrate?: number; + mediaSourceId?: string; + startPositionMs?: number; +} + +export interface CastLoadParams { + client: RemoteMediaClient; + /** Cast device — only `modelName` is read, for capability detection. */ + device: { modelName?: string } | null; + api: Api; + item: BaseItemDto; + userId: string; + profileMode: ChromecastProfileMode; + /** Manual bitrate cap from settings, in bits per second. */ + maxBitrateSetting?: number; + options?: CastLoadOptions; +} + +export type CastLoadResult = { ok: true } | { ok: false; error: unknown }; + +/** + * Resolve the default audio stream index for an item. + * Fixes the previous `audioStreamIndex = 0` default, which selected the video stream. + */ +export const resolveDefaultAudioIndex = ( + item: BaseItemDto, + mediaSourceId?: string, +): number | undefined => { + const source = mediaSourceId + ? item.MediaSources?.find((s) => s.Id === mediaSourceId) + : item.MediaSources?.[0]; + if (source?.DefaultAudioStreamIndex != null) { + return source.DefaultAudioStreamIndex; + } + const audio = + item.MediaStreams?.find((s) => s.Type === "Audio" && s.IsDefault) ?? + item.MediaStreams?.find((s) => s.Type === "Audio"); + return audio?.Index ?? undefined; +}; + +const attemptLoad = async ( + params: CastLoadParams, + caps: Parameters[0], +): Promise => { + const { api, item, userId, client, options } = params; + const profile = buildChromecastProfile(caps); + const audioStreamIndex = + options?.audioStreamIndex ?? + resolveDefaultAudioIndex(item, options?.mediaSourceId); + const startPositionMs = options?.startPositionMs ?? 0; + + const data = await getStreamUrl({ + api, + item, + userId, + startTimeTicks: Math.floor(startPositionMs * 10000), + deviceProfile: profile, + audioStreamIndex, + subtitleStreamIndex: options?.subtitleStreamIndex, + maxStreamingBitrate: options?.maxBitrate, + mediaSourceId: options?.mediaSourceId, + }); + + if (!data?.url) { + throw new Error("getStreamUrl returned no URL"); + } + + await client.loadMedia({ + mediaInfo: buildCastMediaInfo({ + item, + streamUrl: data.url, + api, + playSessionId: data.sessionId ?? undefined, + }), + startTime: startPositionMs / 1000, + }); +}; + +/** + * Load media onto the connected Chromecast. + * On a status-2100 rejection, retries once with a forced conservative profile. + */ +export const loadCastMedia = async ( + params: CastLoadParams, +): Promise => { + const caps = detectCapabilities(params.device, { + profileMode: params.profileMode, + maxBitrate: params.maxBitrateSetting, + }); + + try { + await attemptLoad(params, caps); + return { ok: true }; + } catch (error) { + if (!isLoadFailedError(error)) { + return { ok: false, error }; + } + // Downgrade-on-failure: one retry with the safest possible profile. + try { + const fallback = detectCapabilities(params.device, { + profileMode: "force-h264", + }); + await attemptLoad(params, { + ...fallback, + maxVideoBitrate: 4_000_000, + maxAudioChannels: 2, + }); + return { ok: true }; + } catch (retryError) { + return { ok: false, error: retryError }; + } + } +}; diff --git a/utils/casting/mediaInfo.ts b/utils/casting/mediaInfo.ts index fcc83b9e8..7a88eaecd 100644 --- a/utils/casting/mediaInfo.ts +++ b/utils/casting/mediaInfo.ts @@ -23,6 +23,7 @@ export const buildCastMediaInfo = ({ api, contentType, isLive = false, + playSessionId, }: { item: BaseItemDto; streamUrl: string; @@ -31,6 +32,8 @@ export const buildCastMediaInfo = ({ contentType?: string; /** Set true for live TV streams to use MediaStreamType.LIVE. */ isLive?: boolean; + /** Jellyfin PlaySessionId, embedded in customData for progress reporting. */ + playSessionId?: string; }) => { if (!item.Id) { throw new Error("Missing item.Id for media load — cannot build contentId"); @@ -84,7 +87,8 @@ export const buildCastMediaInfo = ({ // Build a slim customData payload with only the fields the casting-player needs. // Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit, // especially for movies with many chapters, media sources, and people. - const slimCustomData: Partial = { + const slimCustomData: Partial & { playSessionId?: string } = { + playSessionId, Id: item.Id, Name: item.Name, Type: item.Type,