mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-27 11:04:42 +01:00
feat(tv): add language-based audio and subtitle track selection
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user