feat(casting): add unified loadCastMedia with downgrade-on-failure

This commit is contained in:
Uruk
2026-05-21 02:20:06 +02:00
parent 6ecadecb87
commit fb8c649f6f
4 changed files with 173 additions and 1 deletions

View File

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

View File

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

137
utils/casting/castLoad.ts Normal file
View File

@@ -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<typeof buildChromecastProfile>[0],
): Promise<void> => {
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<CastLoadResult> => {
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 };
}
}
};

View File

@@ -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<BaseItemDto> = {
const slimCustomData: Partial<BaseItemDto> & { playSessionId?: string } = {
playSessionId,
Id: item.Id,
Name: item.Name,
Type: item.Type,