diff --git a/utils/casting/castLoad.ts b/utils/casting/castLoad.ts index 704f19d38..2f61c33bf 100644 --- a/utils/casting/castLoad.ts +++ b/utils/casting/castLoad.ts @@ -17,6 +17,7 @@ import { } 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 { @@ -42,26 +43,6 @@ export interface CastLoadParams { 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], diff --git a/utils/casting/selection.test.ts b/utils/casting/selection.test.ts new file mode 100644 index 000000000..9d6886883 --- /dev/null +++ b/utils/casting/selection.test.ts @@ -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); + }); +}); diff --git a/utils/casting/selection.ts b/utils/casting/selection.ts new file mode 100644 index 000000000..a950ec95d --- /dev/null +++ b/utils/casting/selection.ts @@ -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 => { + 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; diff --git a/utils/casting/types.ts b/utils/casting/types.ts index 0db139fa6..4f6e04794 100644 --- a/utils/casting/types.ts +++ b/utils/casting/types.ts @@ -70,3 +70,18 @@ export const DEFAULT_CAST_STATE: CastPlayerState = { volume: 0.5, 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; +}