Merge pull request #7141 from thornbill/subtitle-styling

Extract native/custom subtitle element logic to separate typescript file
This commit is contained in:
Joshua M. Boniface
2025-09-27 17:52:56 -04:00
committed by GitHub
6 changed files with 236 additions and 183 deletions

View File

@@ -0,0 +1,13 @@
/**
* Options specifying if the player's native subtitle (cue) element should be used, a custom element (div), or allow
* Jellyfin to choose automatically based on known browser support. Some browsers do not properly apply CSS styling to
* the native subtitle element.
*/
export const SubtitleStylingOption = {
Auto: 'Auto',
Custom: 'Custom',
Native: 'Native'
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type SubtitleStylingOption = typeof SubtitleStylingOption[keyof typeof SubtitleStylingOption];

View File

@@ -0,0 +1,43 @@
import { SubtitleStylingOption } from 'apps/stable/features/playback/constants/subtitleStylingOption';
import browser from 'scripts/browser';
import type { UserSettings } from 'scripts/settings/userSettings';
// TODO: This type override should be removed when userSettings are properly typed
interface SubtitleAppearanceSettings {
subtitleStyling: SubtitleStylingOption
}
export function useCustomSubtitles(userSettings: UserSettings) {
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings() as SubtitleAppearanceSettings;
switch (subtitleAppearance.subtitleStyling) {
case SubtitleStylingOption.Native:
return false;
case SubtitleStylingOption.Custom:
return true;
default:
// after a system update, ps4 isn't showing anything when creating a track element dynamically
// going to have to do it ourselves
if (browser.ps4) {
return true;
}
// Tizen 5 doesn't support displaying secondary subtitles
if ((browser.tizenVersion && browser.tizenVersion >= 5) || browser.web0s) {
return true;
}
if (browser.edge) {
return true;
}
// font-size styling does not seem to work natively in firefox. Switching to custom subtitles element for firefox.
if (browser.firefox) {
return true;
}
// iOS/macOS global caption settings are causing huge font-size and margins
if (browser.safari) return true;
return false;
}
}

View File

@@ -178,7 +178,6 @@ const Scroller: FC<PropsWithChildren<ScrollerProps>> = ({
allowNativeScroll: !enableScrollButtons,
forceHideScrollbars: enableScrollButtons,
// In edge, with the native scroll, the content jumps around when hovering over the buttons
// @ts-expect-error browser doesn't explicitly declare browser.edge, so fails type checking
requireAnimation: enableScrollButtons && browser.edge
};

View File

@@ -2,8 +2,11 @@ import DOMPurify from 'dompurify';
import debounce from 'lodash-es/debounce';
import Screenfull from 'screenfull';
import { useCustomSubtitles } from 'apps/stable/features/playback/utils/subtitleStyles';
import subtitleAppearanceHelper from 'components/subtitlesettings/subtitleappearancehelper';
import { AppFeature } from 'constants/appFeature';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { currentSettings as userSettings } from 'scripts/settings/userSettings';
import { MediaError } from 'types/mediaError';
import browser from '../../scripts/browser';
@@ -330,8 +333,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
incrementFetchQueue() {
if (this.#fetchQueue <= 0) {
this.isFetching = true;
@@ -342,8 +345,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
decrementFetchQueue() {
this.#fetchQueue--;
@@ -354,8 +357,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
updateVideoUrl(streamInfo) {
const mediaSource = streamInfo.mediaSource;
const item = streamInfo.item;
@@ -406,8 +409,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setSrcWithFlvJs(elem, options, url) {
return import('flv.js').then(({ default: flvjs }) => {
const flvPlayer = flvjs.createPlayer({
@@ -432,8 +435,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setSrcWithHlsJs(elem, options, url) {
return new Promise((resolve, reject) => {
requireHlsPlayer(async () => {
@@ -473,8 +476,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
async setCurrentSrc(elem, options) {
elem.removeEventListener('error', this.onError);
@@ -577,8 +580,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
getTextTracks() {
const videoElement = this.#mediaElement;
if (videoElement) {
@@ -595,8 +598,8 @@ export class HtmlVideoPlayer {
setSubtitleOffset = debounce(this._setSubtitleOffset, 100);
/**
* @private
*/
* @private
*/
_setSubtitleOffset(offset) {
const offsetValue = parseFloat(offset);
@@ -624,8 +627,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
updateCurrentTrackOffset(offsetValue, currentTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
let offsetToCompare = this.#currentTrackOffset;
if (this.isSecondaryTrack(currentTrackIndex)) {
@@ -650,19 +653,19 @@ export class HtmlVideoPlayer {
}
/**
* @private
* These browsers will not clear the existing active cue when setting an offset
* for native TextTracks.
* Any previous text tracks that are on the screen when the offset changes will remain next
* to the new tracks until they reach the end time of the new offset's instance of the track.
*/
* @private
* These browsers will not clear the existing active cue when setting an offset
* for native TextTracks.
* Any previous text tracks that are on the screen when the offset changes will remain next
* to the new tracks until they reach the end time of the new offset's instance of the track.
*/
requiresHidingActiveCuesOnOffsetChange() {
return !!browser.firefox;
}
/**
* @private
*/
* @private
*/
hideTextTrackWithActiveCues(currentTrack) {
if (currentTrack.activeCues) {
currentTrack.mode = 'hidden';
@@ -670,11 +673,11 @@ export class HtmlVideoPlayer {
}
/**
* Forces the active cue to clear by disabling then re-enabling the track.
* The track mode is reverted inside of a 0ms timeout to free up the track
* and allow it to disable and clear the active cue.
* @private
*/
* Forces the active cue to clear by disabling then re-enabling the track.
* The track mode is reverted inside of a 0ms timeout to free up the track
* and allow it to disable and clear the active cue.
* @private
*/
forceClearTextTrackActiveCues(currentTrack) {
if (currentTrack.activeCues) {
currentTrack.mode = 'disabled';
@@ -685,8 +688,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setTextTrackSubtitleOffset(currentTrack, offsetValue, currentTrackIndex) {
if (currentTrack.cues) {
offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex);
@@ -712,8 +715,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setTrackEventsSubtitleOffset(trackEvents, offsetValue, currentTrackIndex) {
if (Array.isArray(trackEvents)) {
offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex) * 1e7; // ticks
@@ -740,8 +743,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
isAudioStreamSupported(stream, deviceProfile, container) {
const codec = (stream.Codec || '').toLowerCase();
@@ -764,8 +767,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
getSupportedAudioStreams() {
const profile = this.#lastProfile;
@@ -807,8 +810,8 @@ export class HtmlVideoPlayer {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/audioTracks
/**
* @type {ArrayLike<any>|any[]}
*/
* @type {ArrayLike<any>|any[]}
*/
const elemAudioTracks = elem.audioTracks || [];
console.debug(`found ${elemAudioTracks.length} audio tracks`);
@@ -890,26 +893,26 @@ export class HtmlVideoPlayer {
}
/**
* @private
* @param e {Event} The event received from the `<video>` element
*/
* @private
* @param e {Event} The event received from the `<video>` element
*/
onEnded = (e) => {
/**
* @type {HTMLMediaElement}
*/
* @type {HTMLMediaElement}
*/
const elem = e.target;
this.destroyCustomTrack(elem);
onEndedInternal(this, elem, this.onError);
};
/**
* @private
* @param e {Event} The event received from the `<video>` element
*/
* @private
* @param e {Event} The event received from the `<video>` element
*/
onTimeUpdate = (e) => {
/**
* @type {HTMLMediaElement}
*/
* @type {HTMLMediaElement}
*/
const elem = e.target;
// get the player position and the transcoding offset
const time = elem.currentTime;
@@ -933,21 +936,21 @@ export class HtmlVideoPlayer {
};
/**
* @private
* @param e {Event} The event received from the `<video>` element
*/
* @private
* @param e {Event} The event received from the `<video>` element
*/
onVolumeChange = (e) => {
/**
* @type {HTMLMediaElement}
*/
* @type {HTMLMediaElement}
*/
const elem = e.target;
saveVolume(elem.volume);
Events.trigger(this, 'volumechange');
};
/**
* @private
*/
* @private
*/
onNavigatedToOsd = () => {
const dlg = this.#videoDialog;
if (dlg) {
@@ -958,8 +961,8 @@ export class HtmlVideoPlayer {
};
/**
* @private
*/
* @private
*/
onStartedAndNavigatedToOsd() {
// If this causes a failure during navigation we end up in an awkward UI state
this.setCurrentTrackElement(this.#subtitleTrackIndexToSetOnPlaying);
@@ -970,23 +973,23 @@ export class HtmlVideoPlayer {
if (this.#secondarySubtitleTrackIndexToSetOnPlaying != null && this.#secondarySubtitleTrackIndexToSetOnPlaying >= 0) {
/**
* Using a 0ms timeout to set the secondary subtitles because of some weird race condition when
* setting both primary and secondary tracks at the same time.
* The `TextTrack` content and cues will somehow get mixed up and each track will play a mix of both languages.
* Putting this in a timeout fixes it completely.
*/
* Using a 0ms timeout to set the secondary subtitles because of some weird race condition when
* setting both primary and secondary tracks at the same time.
* The `TextTrack` content and cues will somehow get mixed up and each track will play a mix of both languages.
* Putting this in a timeout fixes it completely.
*/
setTimeout(() => this.setSecondarySubtitleStreamIndex(this.#secondarySubtitleTrackIndexToSetOnPlaying), 0);
}
}
/**
* @private
* @param e {Event} The event received from the `<video>` element
*/
* @private
* @param e {Event} The event received from the `<video>` element
*/
onPlaying = (e) => {
/**
* @type {HTMLMediaElement}
*/
* @type {HTMLMediaElement}
*/
const elem = e.target;
if (!this.#started) {
this.#started = true;
@@ -1015,15 +1018,15 @@ export class HtmlVideoPlayer {
};
/**
* @private
*/
* @private
*/
onPlay = () => {
Events.trigger(this, 'unpause');
};
/**
* @private
*/
* @private
*/
ensureValidVideo(elem) {
if (elem !== this.#mediaElement) {
return;
@@ -1041,22 +1044,22 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
onClick = () => {
Events.trigger(this, 'click');
};
/**
* @private
*/
* @private
*/
onDblClick = () => {
Events.trigger(this, 'dblclick');
};
/**
* @private
*/
* @private
*/
onPause = () => {
Events.trigger(this, 'pause');
};
@@ -1066,13 +1069,13 @@ export class HtmlVideoPlayer {
}
/**
* @private
* @param e {Event} The event received from the `<video>` element
*/
* @private
* @param e {Event} The event received from the `<video>` element
*/
onError = (e) => {
/**
* @type {HTMLMediaElement}
*/
* @type {HTMLMediaElement}
*/
const elem = e.target;
const errorCode = elem.error ? (elem.error.code || 0) : 0;
const errorMessage = elem.error ? (elem.error.message || '') : '';
@@ -1112,8 +1115,8 @@ export class HtmlVideoPlayer {
};
/**
* @private
*/
* @private
*/
destroyCustomRenderedTrackElements(targetTrackIndex) {
if (this.isPrimaryTrack(targetTrackIndex)) {
if (this.#videoSubtitlesElem) {
@@ -1137,8 +1140,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
destroyNativeTracks(videoElement, targetTrackIndex) {
if (videoElement) {
const destroySingleTrack = typeof targetTrackIndex === 'number';
@@ -1157,8 +1160,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
destroyStoredTrackInfo(targetTrackIndex) {
if (this.isPrimaryTrack(targetTrackIndex)) {
this.#customTrackIndex = -1;
@@ -1175,8 +1178,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
destroyCustomTrack(videoElement, targetTrackIndex) {
this.destroyCustomRenderedTrackElements(targetTrackIndex);
this.destroyNativeTracks(videoElement, targetTrackIndex);
@@ -1196,8 +1199,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
fetchSubtitlesUwp(track) {
return Windows.Storage.StorageFile.getFileFromPathAsync(track.Path).then(function (storageFile) {
return Windows.Storage.FileIO.readTextAsync(storageFile);
@@ -1207,8 +1210,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
async fetchSubtitles(track, item) {
if (window.Windows && itemHelper.isLocalItem(item)) {
return this.fetchSubtitlesUwp(track, item);
@@ -1229,8 +1232,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setTrackForDisplay(videoElement, track, targetTextTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
if (!track) {
// Destroy all tracks by passing undefined if there is no valid primary track
@@ -1262,8 +1265,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
renderSsaAss(videoElement, track, item) {
const supportedFonts = ['application/vnd.ms-opentype', 'application/x-truetype-font', 'font/otf', 'font/ttf', 'font/woff', 'font/woff2'];
const availableFonts = [];
@@ -1358,54 +1361,10 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
requiresCustomSubtitlesElement(userSettings) {
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings();
switch (subtitleAppearance.subtitleStyling) {
case 'Native':
return false;
case 'Custom':
return true;
default:
// after a system update, ps4 isn't showing anything when creating a track element dynamically
// going to have to do it ourselves
if (browser.ps4) {
return true;
}
// Tizen 5 doesn't support displaying secondary subtitles
if (browser.tizenVersion >= 5 || browser.web0s) {
return true;
}
if (browser.edge) {
return true;
}
// font-size styling does not seem to work natively in firefox. Switching to custom subtitles element for firefox.
if (browser.firefox) {
return true;
}
if (browser.iOS) {
const userAgent = navigator.userAgent.toLowerCase();
// works in the browser but not the native app
if ((userAgent.includes('os 9') || userAgent.includes('os 8')) && !userAgent.includes('safari')) {
return true;
}
}
return false;
}
}
/**
* @private
*/
* @private
*/
renderSubtitlesWithCustomElement(videoElement, track, item, targetTextTrackIndex) {
Promise.all([import('../../scripts/settings/userSettings'), this.fetchSubtitles(track, item)]).then((results) => {
const [userSettings, subtitleData] = results;
this.fetchSubtitles(track, item).then((subtitleData) => {
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings();
const subtitleVerticalPosition = parseInt(subtitleAppearance.verticalPosition, 10);
@@ -1441,20 +1400,18 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setSubtitleAppearance(elem, innerElem) {
Promise.all([import('../../scripts/settings/userSettings'), import('../../components/subtitlesettings/subtitleappearancehelper')]).then(([userSettings, subtitleAppearanceHelper]) => {
subtitleAppearanceHelper.applyStyles({
text: innerElem,
window: elem
}, userSettings.getSubtitleAppearanceSettings());
});
subtitleAppearanceHelper.applyStyles({
text: innerElem,
window: elem
}, userSettings.getSubtitleAppearanceSettings());
}
/**
* @private
*/
* @private
*/
getCueCss(appearance, selector) {
return `${selector}::cue {
${appearance.text.map((s) => s.value !== undefined && s.value !== '' ? `${s.name}:${s.value}!important;` : '').join('')}
@@ -1462,29 +1419,25 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setCueAppearance() {
Promise.all([import('../../scripts/settings/userSettings'), import('../../components/subtitlesettings/subtitleappearancehelper')]).then(([userSettings, subtitleAppearanceHelper]) => {
const elementId = `${this.id}-cuestyle`;
const elementId = `${this.id}-cuestyle`;
let styleElem = document.querySelector(`#${elementId}`);
if (!styleElem) {
styleElem = document.createElement('style');
styleElem.id = elementId;
document.getElementsByTagName('head')[0].appendChild(styleElem);
}
let styleElem = document.querySelector(`#${elementId}`);
if (!styleElem) {
styleElem = document.createElement('style');
styleElem.id = elementId;
document.getElementsByTagName('head')[0].appendChild(styleElem);
}
styleElem.innerHTML = this.getCueCss(subtitleAppearanceHelper.getStyles(userSettings.getSubtitleAppearanceSettings()), '.htmlvideoplayer');
});
styleElem.innerHTML = this.getCueCss(subtitleAppearanceHelper.getStyles(userSettings.getSubtitleAppearanceSettings()), '.htmlvideoplayer');
}
/**
* @private
*/
* @private
*/
async renderTracksEvents(videoElement, track, item, targetTextTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
const { currentSettings: userSettings } = await import('../../scripts/settings/userSettings');
if (!itemHelper.isLocalItem(item) || track.IsExternal) {
const format = (track.Codec || '').toLowerCase();
if (format === 'ssa' || format === 'ass') {
@@ -1496,7 +1449,7 @@ export class HtmlVideoPlayer {
return;
}
if (this.requiresCustomSubtitlesElement(userSettings)) {
if (useCustomSubtitles(userSettings)) {
this.renderSubtitlesWithCustomElement(videoElement, track, item, targetTextTrackIndex);
return;
}
@@ -1555,8 +1508,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
updateSubtitleText(timeMs) {
const allTrackEvents = [this.#currentTrackEvents, this.#currentSecondaryTrackEvents];
const subtitleTextElements = [this.#videoSubtitlesElem, this.#videoSecondarySubtitlesElem];
@@ -1587,8 +1540,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
setCurrentTrackElement(streamIndex, targetTextTrackIndex) {
console.debug(`setting new text track index to: ${streamIndex}`);
@@ -1648,8 +1601,8 @@ export class HtmlVideoPlayer {
}
/**
* @private
*/
* @private
*/
createMediaElement(options) {
const dlg = document.querySelector('.videoPlayerContainer');

45
src/scripts/browser.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
export default browser;
/**
* Browser detection utility.
*/
declare namespace browser {
import version = version;
export { version };
import versionMajor = versionMajor;
export { versionMajor };
export let edge: boolean;
export let edgeChromium: boolean;
export let firefox: boolean;
export let safari: boolean;
export let osx: boolean;
export let ipad: boolean;
export let ps4: boolean;
export let tv: boolean;
export let mobile: boolean;
export let xboxOne: boolean;
export let animate: boolean;
export let hisense: boolean;
export let tizen: boolean;
export let vidaa: boolean;
export let web0s: boolean;
export let edgeUwp: boolean;
export let web0sVersion: number | undefined;
export let tizenVersion: number | undefined;
export let orsay: boolean;
export let operaTv: boolean;
export let slow: boolean;
export let touch: boolean;
export let keyboard: boolean;
export { supportsCssAnimation };
export let iOS: boolean;
export let iOSVersion: number | undefined;
}
declare namespace matched {
export { browser };
export { version };
export let platform: string;
export { versionMajor };
}
declare function supportsCssAnimation(allowPrefix: any): any;

View File

@@ -600,7 +600,7 @@ export class UserSettings {
/**
* Get subtitle appearance settings.
* @param {string|undefined} key - Settings key.
* @param {string|undefined} [key] - Settings key.
* @return {Object} Subtitle appearance settings.
*/
getSubtitleAppearanceSettings(key) {