feat(tv): add language-based audio and subtitle track selection

This commit is contained in:
Fredrik Burmester
2026-01-26 19:32:06 +01:00
parent 111397a306
commit 7fe24369c0
3 changed files with 205 additions and 6 deletions

View File

@@ -112,12 +112,22 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
SelectedOptions | undefined
>(undefined);
// Enable language preference application for TV
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),

View File

@@ -1,19 +1,27 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import type { Settings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import {
getDefaultPlaySettings,
type PlaySettingsOptions,
} from "@/utils/jellyfin/getDefaultPlaySettings";
/**
* React hook wrapper for getDefaultPlaySettings.
* Used in UI components for initial playback (no previous track state).
*
* @param item - The media item to play
* @param settings - User settings (language preferences, bitrate, etc.)
* @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
*/
const useDefaultPlaySettings = (
item: BaseItemDto | null | undefined,
settings: Settings | null,
options?: PlaySettingsOptions,
) =>
useMemo(() => {
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
getDefaultPlaySettings(item, settings);
getDefaultPlaySettings(item, settings, undefined, options);
return {
defaultMediaSource: mediaSource,
@@ -21,6 +29,6 @@ const useDefaultPlaySettings = (
defaultSubtitleIndex: subtitleIndex,
defaultBitrate: bitrate,
};
}, [item, settings]);
}, [item, settings, options]);
export default useDefaultPlaySettings;

View File

@@ -12,7 +12,9 @@
import type {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { BITRATES } from "@/components/BitrateSelector";
import { type Settings } from "../atoms/settings";
import {
@@ -34,17 +36,144 @@ export interface PreviousIndexes {
subtitleIndex?: number;
}
export interface PlaySettingsOptions {
/** Apply language preferences from settings (used on TV) */
applyLanguagePreferences?: boolean;
}
/**
* Find a track by language code.
*
* @param streams - Available media streams
* @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe")
* @param streamType - Type of stream to search ("Audio" or "Subtitle")
* @param forcedOnly - If true, only match forced subtitles
* @returns The stream index if found, undefined otherwise
*/
function findTrackByLanguage(
streams: MediaStream[],
languageCode: string | undefined,
streamType: "Audio" | "Subtitle",
forcedOnly = false,
): number | undefined {
if (!languageCode) return undefined;
const candidates = streams.filter((s) => {
if (s.Type !== streamType) return false;
if (forcedOnly && !s.IsForced) return false;
// Match on ThreeLetterISOLanguageName (ISO 639-2)
return (
s.Language?.toLowerCase() === languageCode.toLowerCase() ||
// Fallback: some Jellyfin servers use two-letter codes in Language field
s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase()
);
});
// Prefer default track if multiple match
const defaultTrack = candidates.find((s) => s.IsDefault);
return defaultTrack?.Index ?? candidates[0]?.Index;
}
/**
* Apply subtitle mode logic to determine the final subtitle index.
*
* @param streams - Available media streams
* @param settings - User settings containing subtitleMode
* @param defaultIndex - The current default subtitle index
* @param audioLanguage - The selected audio track's language (for Smart mode)
* @param subtitleLanguageCode - The user's preferred subtitle language
* @returns The final subtitle index (-1 for disabled)
*/
function applySubtitleMode(
streams: MediaStream[],
settings: Settings,
defaultIndex: number,
audioLanguage: string | undefined,
subtitleLanguageCode: string | undefined,
): number {
const subtitleStreams = streams.filter((s) => s.Type === "Subtitle");
const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default;
switch (mode) {
case SubtitlePlaybackMode.None:
// Always disable subtitles
return -1;
case SubtitlePlaybackMode.OnlyForced: {
// Only show forced subtitles, prefer matching language
const forcedMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
true,
);
if (forcedMatch !== undefined) return forcedMatch;
// Fallback to any forced subtitle
const anyForced = subtitleStreams.find((s) => s.IsForced);
return anyForced?.Index ?? -1;
}
case SubtitlePlaybackMode.Always: {
// Always enable subtitles, prefer language match
const alwaysMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
if (alwaysMatch !== undefined) return alwaysMatch;
// Fallback to first available or current default
return subtitleStreams[0]?.Index ?? defaultIndex;
}
case SubtitlePlaybackMode.Smart: {
// Enable subtitles only when audio language differs from subtitle preference
if (audioLanguage && subtitleLanguageCode) {
const audioLang = audioLanguage.toLowerCase();
const subLang = subtitleLanguageCode.toLowerCase();
// If audio matches subtitle preference, disable subtitles
if (
audioLang === subLang ||
audioLang.startsWith(subLang.substring(0, 2)) ||
subLang.startsWith(audioLang.substring(0, 2))
) {
return -1;
}
}
// Audio doesn't match preference, enable subtitles
const smartMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
return smartMatch ?? subtitleStreams[0]?.Index ?? -1;
}
default:
// Use language preference if set, else keep Jellyfin default
if (subtitleLanguageCode) {
const langMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
if (langMatch !== undefined) return langMatch;
}
return defaultIndex;
}
}
/**
* Get default play settings for an item.
*
* @param item - The media item to play
* @param settings - User settings (language preferences, bitrate, etc.)
* @param previous - Optional previous track selections to carry over (for sequential play)
* @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
*/
export function getDefaultPlaySettings(
item: BaseItemDto | null | undefined,
settings: Settings | null,
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
options?: PlaySettingsOptions,
): PlaySettings {
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
@@ -65,6 +194,10 @@ export function getDefaultPlaySettings(
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
// Track whether we matched previous selections (for language preference fallback)
let matchedPreviousAudio = false;
let matchedPreviousSubtitle = false;
// Try to match previous selections (sequential play)
if (previous?.indexes && previous?.source && settings) {
if (
@@ -79,7 +212,11 @@ export function getDefaultPlaySettings(
streams,
result,
);
subtitleIndex = result.DefaultSubtitleStreamIndex;
// Check if StreamRanker found a match (changed from default)
if (result.DefaultSubtitleStreamIndex !== subtitleIndex) {
subtitleIndex = result.DefaultSubtitleStreamIndex;
matchedPreviousSubtitle = true;
}
}
if (
@@ -94,7 +231,51 @@ export function getDefaultPlaySettings(
streams,
result,
);
audioIndex = result.DefaultAudioStreamIndex;
// Check if StreamRanker found a match (changed from default)
if (result.DefaultAudioStreamIndex !== audioIndex) {
audioIndex = result.DefaultAudioStreamIndex;
matchedPreviousAudio = true;
}
}
}
// Apply language preferences when enabled (TV) and no previous selection matched
if (options?.applyLanguagePreferences && settings) {
const audioLanguageCode =
settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined;
const subtitleLanguageCode =
settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined;
// Apply audio language preference if no previous selection matched
if (!matchedPreviousAudio && audioLanguageCode) {
const langMatch = findTrackByLanguage(
streams,
audioLanguageCode,
"Audio",
);
if (langMatch !== undefined) {
audioIndex = langMatch;
}
}
// Get the selected audio track's language for Smart mode
const selectedAudioTrack = streams.find(
(s) => s.Type === "Audio" && s.Index === audioIndex,
);
const selectedAudioLanguage =
selectedAudioTrack?.Language ??
selectedAudioTrack?.DisplayTitle ??
undefined;
// Apply subtitle mode logic if no previous selection matched
if (!matchedPreviousSubtitle) {
subtitleIndex = applySubtitleMode(
streams,
settings,
subtitleIndex,
selectedAudioLanguage,
subtitleLanguageCode,
);
}
}