From 7fe24369c0f3f27f2a6de69434bf26661e1119fe Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:32:06 +0100 Subject: [PATCH] feat(tv): add language-based audio and subtitle track selection --- components/ItemContent.tv.tsx | 12 +- hooks/useDefaultPlaySettings.ts | 14 +- utils/jellyfin/getDefaultPlaySettings.ts | 185 ++++++++++++++++++++++- 3 files changed, 205 insertions(+), 6 deletions(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 21738e97..0494f108 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -112,12 +112,22 @@ export const ItemContentTV: React.FC = 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), diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index 255a114d..ad292639 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -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; diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index fba1dbe1..bfbfb526 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -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, + ); } }