246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
import type { PlayerSubtitleInfo, PlayerSubtitleTrackSignature, SubtitleMode } from "../types";
|
|
|
|
const ORIGINAL_LANGUAGE_SENTINEL = "original";
|
|
|
|
const SOURCE_PRIORITY: Record<string, number> = {
|
|
external: 0,
|
|
downloaded: 1,
|
|
embedded: 2,
|
|
};
|
|
|
|
const ISO639_TO_3: Record<string, string> = {
|
|
en: "eng",
|
|
es: "spa",
|
|
fr: "fre",
|
|
de: "ger",
|
|
it: "ita",
|
|
pt: "por",
|
|
nl: "dut",
|
|
pl: "pol",
|
|
sv: "swe",
|
|
no: "nor",
|
|
da: "dan",
|
|
fi: "fin",
|
|
ru: "rus",
|
|
uk: "ukr",
|
|
cs: "cze",
|
|
sk: "slo",
|
|
hu: "hun",
|
|
ro: "rum",
|
|
bg: "bul",
|
|
hr: "hrv",
|
|
sl: "slv",
|
|
sr: "srp",
|
|
tr: "tur",
|
|
el: "gre",
|
|
he: "heb",
|
|
ar: "ara",
|
|
zh: "chi",
|
|
ja: "jpn",
|
|
ko: "kor",
|
|
vi: "vie",
|
|
th: "tha",
|
|
id: "ind",
|
|
ms: "may",
|
|
hi: "hin",
|
|
};
|
|
|
|
function normalize(value: string | undefined | null): string {
|
|
return (value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeConcreteLanguage(value: string | undefined | null): string | null {
|
|
const normalized = normalize(value);
|
|
if (!normalized || normalized === ORIGINAL_LANGUAGE_SENTINEL) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function sameLanguageCode(a: string | undefined | null, b: string | undefined | null): boolean {
|
|
const left = normalizeConcreteLanguage(a);
|
|
const right = normalizeConcreteLanguage(b);
|
|
if (!left || !right) return false;
|
|
if (left === right) return true;
|
|
return (ISO639_TO_3[left] ?? left) === (ISO639_TO_3[right] ?? right);
|
|
}
|
|
|
|
function sameLanguage(track: PlayerSubtitleInfo, language: string): boolean {
|
|
return sameLanguageCode(track.language, language);
|
|
}
|
|
|
|
function subtitleTrackMatchesSignature(
|
|
track: PlayerSubtitleInfo,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): boolean {
|
|
if (!signature) return false;
|
|
return (
|
|
normalize(track.source) === normalize(signature.source) &&
|
|
normalize(track.language) === normalize(signature.language) &&
|
|
normalize(track.codec) === normalize(signature.codec) &&
|
|
(normalize(signature.label) === "" || normalize(track.label) === normalize(signature.label)) &&
|
|
Boolean(track.forced) === Boolean(signature.forced) &&
|
|
Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)
|
|
);
|
|
}
|
|
|
|
function findExactSubtitleSignatureMatch(
|
|
tracks: PlayerSubtitleInfo[],
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number | null {
|
|
if (!signature) return null;
|
|
const match = tracks.find((track) => subtitleTrackMatchesSignature(track, signature));
|
|
return match?.index ?? null;
|
|
}
|
|
|
|
function scoreSignatureFallback(
|
|
track: PlayerSubtitleInfo,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number {
|
|
if (!signature) return 0;
|
|
let score = 0;
|
|
if (normalize(track.source) === normalize(signature.source)) score += 4;
|
|
if (Boolean(track.forced) === Boolean(signature.forced)) score += 2;
|
|
if (Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)) score += 2;
|
|
if (normalize(track.codec) === normalize(signature.codec)) score += 1;
|
|
if (normalize(track.label) === normalize(signature.label)) score += 1;
|
|
return score;
|
|
}
|
|
|
|
/** Sort subtitle tracks: external first, then downloaded, then embedded. */
|
|
export function sortSubtitlesBySource(tracks: PlayerSubtitleInfo[]): PlayerSubtitleInfo[] {
|
|
return [...tracks].sort((a, b) => {
|
|
const pa = SOURCE_PRIORITY[a.source ?? "embedded"] ?? 2;
|
|
const pb = SOURCE_PRIORITY[b.source ?? "embedded"] ?? 2;
|
|
return pa - pb;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find the best subtitle track index for a given language,
|
|
* preferring external > downloaded > embedded.
|
|
* Returns the track's backend index (track.index) or -1 if no match.
|
|
*/
|
|
export function findPreferredSubtitleIndex(tracks: PlayerSubtitleInfo[], language: string): number {
|
|
let bestIdx = -1;
|
|
let bestPriority = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
if (!track || !sameLanguage(track, language)) continue;
|
|
const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2;
|
|
if (priority < bestPriority) {
|
|
bestPriority = priority;
|
|
bestIdx = track.index;
|
|
}
|
|
}
|
|
|
|
return bestIdx;
|
|
}
|
|
|
|
function findPreferredSubtitleIndexWithSignature(
|
|
tracks: PlayerSubtitleInfo[],
|
|
language: string,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number {
|
|
let bestTrack: PlayerSubtitleInfo | null = null;
|
|
let bestScore = -1;
|
|
let bestPriority = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
if (!track || !sameLanguage(track, language)) continue;
|
|
const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2;
|
|
const score = scoreSignatureFallback(track, signature);
|
|
if (
|
|
bestTrack === null ||
|
|
score > bestScore ||
|
|
(score === bestScore && priority < bestPriority)
|
|
) {
|
|
bestTrack = track;
|
|
bestScore = score;
|
|
bestPriority = priority;
|
|
}
|
|
}
|
|
|
|
return bestTrack?.index ?? -1;
|
|
}
|
|
|
|
export interface SubtitleAutoSelectOptions {
|
|
mode: SubtitleMode;
|
|
tracks: PlayerSubtitleInfo[];
|
|
preferredLanguage: string | null;
|
|
preferredTrackSignature?: PlayerSubtitleTrackSignature | null;
|
|
audioLanguage: string | null;
|
|
profileLanguage: string | null;
|
|
showForcedSubtitles: boolean;
|
|
}
|
|
|
|
function findForcedSubtitleIndex(
|
|
tracks: PlayerSubtitleInfo[],
|
|
language: string | null | undefined,
|
|
): number | null {
|
|
if (!language) return null;
|
|
const match = findPreferredSubtitleIndex(
|
|
tracks.filter((track) => track.forced),
|
|
language,
|
|
);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
/**
|
|
* Determines which subtitle track to auto-select on playback start.
|
|
* Returns the track's backend index, or null if no track should be selected.
|
|
*/
|
|
export function resolveSubtitleAutoSelect(options: SubtitleAutoSelectOptions): number | null {
|
|
const {
|
|
mode,
|
|
tracks,
|
|
preferredLanguage,
|
|
preferredTrackSignature,
|
|
audioLanguage,
|
|
profileLanguage,
|
|
showForcedSubtitles,
|
|
} = options;
|
|
const signature = preferredTrackSignature ?? null;
|
|
|
|
if (tracks.length === 0) return null;
|
|
const preferredSubtitleLang = normalizeConcreteLanguage(preferredLanguage);
|
|
const normalizedProfileLanguage = normalize(profileLanguage);
|
|
const effectiveProfileLang =
|
|
normalizeConcreteLanguage(profileLanguage) ??
|
|
(normalizedProfileLanguage === ORIGINAL_LANGUAGE_SENTINEL
|
|
? preferredSubtitleLang
|
|
: normalizedProfileLanguage === ""
|
|
? "en"
|
|
: null);
|
|
const effectiveAudioLang = normalizeConcreteLanguage(audioLanguage) ?? effectiveProfileLang;
|
|
|
|
switch (mode) {
|
|
case "off":
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
|
|
case "always": {
|
|
const exactMatch = findExactSubtitleSignatureMatch(tracks, signature);
|
|
if (exactMatch !== null) return exactMatch;
|
|
if (!preferredLanguage) return null;
|
|
const match = findPreferredSubtitleIndexWithSignature(tracks, preferredLanguage, signature);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
case "auto": {
|
|
if (preferredLanguage === "") return null;
|
|
if (effectiveProfileLang && sameLanguageCode(effectiveAudioLang, effectiveProfileLang)) {
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
}
|
|
const lang = preferredSubtitleLang ?? effectiveProfileLang;
|
|
if (!lang) {
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
}
|
|
const match = findPreferredSubtitleIndexWithSignature(tracks, lang, signature);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|