mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
/**
|
|
* 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 { resolveDefaultAudioIndex } from "@/utils/casting/selection";
|
|
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 };
|
|
|
|
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.
|
|
// The bitrate cap must also be applied to the explicit getStreamUrl
|
|
// `maxBitrate` request param — Jellyfin uses that as the effective
|
|
// ceiling, so the conservative profile alone would not lower it.
|
|
try {
|
|
const fallback = detectCapabilities(params.device, {
|
|
profileMode: "force-h264",
|
|
});
|
|
const FALLBACK_MAX_BITRATE = 4_000_000;
|
|
const fallbackParams: CastLoadParams = {
|
|
...params,
|
|
options: {
|
|
...params.options,
|
|
maxBitrate: Math.min(
|
|
params.options?.maxBitrate ?? Number.POSITIVE_INFINITY,
|
|
FALLBACK_MAX_BITRATE,
|
|
),
|
|
},
|
|
};
|
|
await attemptLoad(fallbackParams, {
|
|
...fallback,
|
|
maxVideoBitrate: FALLBACK_MAX_BITRATE,
|
|
maxAudioChannels: 2,
|
|
});
|
|
return { ok: true };
|
|
} catch (retryError) {
|
|
return { ok: false, error: retryError };
|
|
}
|
|
}
|
|
};
|