mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
feat(casting): add CastSelection model and resolution helpers
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/utils/casting/capabilities";
|
} from "@/utils/casting/capabilities";
|
||||||
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
||||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||||
|
import { resolveDefaultAudioIndex } from "@/utils/casting/selection";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
|
||||||
export interface CastLoadOptions {
|
export interface CastLoadOptions {
|
||||||
@@ -42,26 +43,6 @@ export interface CastLoadParams {
|
|||||||
|
|
||||||
export type CastLoadResult = { ok: true } | { ok: false; error: unknown };
|
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 (
|
const attemptLoad = async (
|
||||||
params: CastLoadParams,
|
params: CastLoadParams,
|
||||||
caps: Parameters<typeof buildChromecastProfile>[0],
|
caps: Parameters<typeof buildChromecastProfile>[0],
|
||||||
|
|||||||
88
utils/casting/selection.test.ts
Normal file
88
utils/casting/selection.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
resolveDefaultAudioIndex,
|
||||||
|
resolveSelection,
|
||||||
|
selectionsEqual,
|
||||||
|
} from "./selection";
|
||||||
|
|
||||||
|
const item: BaseItemDto = {
|
||||||
|
Id: "item-1",
|
||||||
|
MediaSources: [
|
||||||
|
{
|
||||||
|
Id: "src-a",
|
||||||
|
DefaultAudioStreamIndex: 2,
|
||||||
|
DefaultSubtitleStreamIndex: 3,
|
||||||
|
MediaStreams: [
|
||||||
|
{ Type: "Video", Index: 0 },
|
||||||
|
{ Type: "Audio", Index: 1, IsDefault: false },
|
||||||
|
{ Type: "Audio", Index: 2, IsDefault: true },
|
||||||
|
{ Type: "Subtitle", Index: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "src-b",
|
||||||
|
MediaStreams: [
|
||||||
|
{ Type: "Video", Index: 0 },
|
||||||
|
{ Type: "Audio", Index: 1, IsDefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveDefaultAudioIndex", () => {
|
||||||
|
test("uses the source's DefaultAudioStreamIndex when present", () => {
|
||||||
|
expect(resolveDefaultAudioIndex(item, "src-a")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the first audio stream when no default flag", () => {
|
||||||
|
expect(resolveDefaultAudioIndex(item, "src-b")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSelection", () => {
|
||||||
|
test("fills every field from server defaults of the first source", () => {
|
||||||
|
const sel = resolveSelection(item, {});
|
||||||
|
expect(sel.mediaSourceId).toBe("src-a");
|
||||||
|
expect(sel.audioStreamIndex).toBe(2);
|
||||||
|
expect(sel.subtitleStreamIndex).toBe(3);
|
||||||
|
expect(sel.maxBitrate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a partial overrides defaults and keeps the rest", () => {
|
||||||
|
const sel = resolveSelection(item, {
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
maxBitrate: 4_000_000,
|
||||||
|
});
|
||||||
|
expect(sel.audioStreamIndex).toBe(1);
|
||||||
|
expect(sel.maxBitrate).toBe(4_000_000);
|
||||||
|
expect(sel.subtitleStreamIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching version resolves that version's defaults", () => {
|
||||||
|
const sel = resolveSelection(item, { mediaSourceId: "src-b" });
|
||||||
|
expect(sel.mediaSourceId).toBe("src-b");
|
||||||
|
expect(sel.audioStreamIndex).toBe(1);
|
||||||
|
expect(sel.subtitleStreamIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectionsEqual", () => {
|
||||||
|
test("true for identical selections", () => {
|
||||||
|
const a = {
|
||||||
|
mediaSourceId: "s",
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
subtitleStreamIndex: -1,
|
||||||
|
};
|
||||||
|
expect(selectionsEqual(a, { ...a })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("false when any field differs", () => {
|
||||||
|
const a = {
|
||||||
|
mediaSourceId: "s",
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
subtitleStreamIndex: -1,
|
||||||
|
};
|
||||||
|
expect(selectionsEqual(a, { ...a, audioStreamIndex: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
utils/casting/selection.ts
Normal file
59
utils/casting/selection.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Cast selection resolution — pure helpers, no React Native imports, so they
|
||||||
|
* are unit-testable under `bun test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { CastSelection } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the default audio stream index for an item / media source.
|
||||||
|
* Prefers the source's `DefaultAudioStreamIndex`, then the first audio 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 streams = source?.MediaStreams ?? item.MediaStreams;
|
||||||
|
const audio =
|
||||||
|
streams?.find((s) => s.Type === "Audio" && s.IsDefault) ??
|
||||||
|
streams?.find((s) => s.Type === "Audio");
|
||||||
|
return audio?.Index ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a partial selection with the item's server defaults.
|
||||||
|
* Used on first load, on episode change, and when switching version.
|
||||||
|
*/
|
||||||
|
export const resolveSelection = (
|
||||||
|
item: BaseItemDto,
|
||||||
|
partial: Partial<CastSelection>,
|
||||||
|
): CastSelection => {
|
||||||
|
const mediaSourceId =
|
||||||
|
partial.mediaSourceId ?? item.MediaSources?.[0]?.Id ?? "";
|
||||||
|
const source = item.MediaSources?.find((s) => s.Id === mediaSourceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSourceId,
|
||||||
|
audioStreamIndex:
|
||||||
|
partial.audioStreamIndex ??
|
||||||
|
resolveDefaultAudioIndex(item, mediaSourceId) ??
|
||||||
|
-1,
|
||||||
|
subtitleStreamIndex:
|
||||||
|
partial.subtitleStreamIndex ?? source?.DefaultSubtitleStreamIndex ?? -1,
|
||||||
|
maxBitrate: partial.maxBitrate,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when two selections are equivalent — used to reconcile optimistic state. */
|
||||||
|
export const selectionsEqual = (a: CastSelection, b: CastSelection): boolean =>
|
||||||
|
a.mediaSourceId === b.mediaSourceId &&
|
||||||
|
a.audioStreamIndex === b.audioStreamIndex &&
|
||||||
|
a.subtitleStreamIndex === b.subtitleStreamIndex &&
|
||||||
|
a.maxBitrate === b.maxBitrate;
|
||||||
@@ -70,3 +70,18 @@ export const DEFAULT_CAST_STATE: CastPlayerState = {
|
|||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
isBuffering: false,
|
isBuffering: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is currently loaded on the cast — the single source of truth for
|
||||||
|
* audio / subtitle / quality / version selection.
|
||||||
|
*/
|
||||||
|
export interface CastSelection {
|
||||||
|
/** MediaSource (version) id. */
|
||||||
|
mediaSourceId: string;
|
||||||
|
/** Absolute MediaStream index of the audio track. */
|
||||||
|
audioStreamIndex: number;
|
||||||
|
/** Absolute MediaStream index of the subtitle track; -1 = subtitles off. */
|
||||||
|
subtitleStreamIndex: number;
|
||||||
|
/** Quality cap in bits/second; undefined = unconstrained. */
|
||||||
|
maxBitrate?: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user