diff --git a/package-lock.json b/package-lock.json
index bbf8018388..c567d86eb2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,7 +49,7 @@
"jquery": "3.7.1",
"jstree": "3.3.17",
"libarchive.js": "2.0.2",
- "libpgs": "0.8.1",
+ "libbitsub": "1.10.2",
"lodash-es": "4.18.1",
"markdown-it": "14.3.0",
"material-design-icons-iconfont": "6.7.0",
@@ -7318,6 +7318,12 @@
"@xtuc/long": "4.2.2"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.71",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
+ "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.9.tgz",
@@ -14987,11 +14993,17 @@
"comlink": "^4.4.1"
}
},
- "node_modules/libpgs": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/libpgs/-/libpgs-0.8.1.tgz",
- "integrity": "sha512-Z8WCvHRYr37QjU8b2WqiJpCvnoojbYPtoE4FktwdI+m+b1QkNIKqrYQrlPKG1xvYyQWgV1j+16xjFOhun4nBUA==",
- "license": "MIT"
+ "node_modules/libbitsub": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/libbitsub/-/libbitsub-1.10.2.tgz",
+ "integrity": "sha512-cXD1ueeeXqJIaNvYRrkNZOZ0A5FMMv9+8Yp/lHJZXPvkim5h0yvF2G27lYr0vGN9HwZCaJhkz6GArRerieTtuw==",
+ "license": "MIT",
+ "dependencies": {
+ "@webgpu/types": "^0.1.71"
+ },
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/lie": {
"version": "3.1.1",
diff --git a/package.json b/package.json
index 93ae12c722..0af828261b 100644
--- a/package.json
+++ b/package.json
@@ -117,7 +117,7 @@
"jquery": "3.7.1",
"jstree": "3.3.17",
"libarchive.js": "2.0.2",
- "libpgs": "0.8.1",
+ "libbitsub": "1.10.2",
"lodash-es": "4.18.1",
"markdown-it": "14.3.0",
"material-design-icons-iconfont": "6.7.0",
diff --git a/src/apps/legacy/controllers/playback/video/index.js b/src/apps/legacy/controllers/playback/video/index.js
index 8737a03078..fdde2c5582 100644
--- a/src/apps/legacy/controllers/playback/video/index.js
+++ b/src/apps/legacy/controllers/playback/video/index.js
@@ -622,6 +622,8 @@ export default function (view) {
Events.off(player, 'timeupdate', onTimeUpdate);
Events.off(player, 'fullscreenchange', onFullscreenChanged);
Events.off(player, 'mediastreamschange', onMediaStreamsChanged);
+ Events.off(player, 'beginFetch', onBeginFetch);
+ Events.off(player, 'endFetch', onEndFetch);
currentPlayer = null;
}
}
diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js
index c4dbbdbe98..c715f4da21 100644
--- a/src/components/playback/playbackmanager.js
+++ b/src/components/playback/playbackmanager.js
@@ -644,6 +644,30 @@ function showPlaybackInfoErrorMessage(instance, errorCode) {
});
}
+function isExternalGraphicalSubtitleStream(stream) {
+ if (!stream || stream.Type !== 'Subtitle' || stream.DeliveryMethod !== 'External') {
+ return false;
+ }
+
+ if (typeof stream.IsTextSubtitleStream === 'boolean') {
+ return !stream.IsTextSubtitleStream;
+ }
+
+ const codec = (stream.Codec || '').toLowerCase();
+ return codec === 'pgssub' || codec === 'dvdsub' || codec === 'vobsub';
+}
+
+function hasSelectedExternalGraphicalSubtitle(mediaSource) {
+ const selectedSubtitleIndices = [mediaSource.DefaultSubtitleStreamIndex, mediaSource.DefaultSecondarySubtitleStreamIndex]
+ .filter(index => typeof index === 'number' && index >= 0);
+
+ if (selectedSubtitleIndices.length === 0) {
+ return false;
+ }
+
+ return (mediaSource.MediaStreams || []).some(stream => selectedSubtitleIndices.includes(stream.Index) && isExternalGraphicalSubtitleStream(stream));
+}
+
function normalizePlayOptions(playOptions) {
playOptions.fullscreen = playOptions.fullscreen !== false;
}
@@ -875,10 +899,15 @@ export class PlaybackManager {
self.trackHasSecondarySubtitleSupport = function (track, player = self._currentPlayer) {
if (!player || !track) return false;
const format = (track.Codec || '').toLowerCase();
- // Currently, only non-SSA/non-ASS external subtitles are supported.
- // Showing secondary subtitles does not work with any SSA/ASS subtitle combinations because
- // of the complexity of how they are rendered and the risk of the subtitles overlapping
- return format !== 'ssa' && format !== 'ass' && getDeliveryMethod(track) === 'External';
+ // Secondary subtitle pairing does not work with SSA/ASS combinations because
+ // of the complexity of how they are rendered and the risk of the subtitles overlapping.
+ // Graphical subtitle formats are supported generally, but not for secondary pairing here.
+ return format !== 'ssa'
+ && format !== 'ass'
+ && format !== 'pgssub'
+ && format !== 'dvdsub'
+ && format !== 'vobsub'
+ && getDeliveryMethod(track) === 'External';
};
self.secondarySubtitleTracks = function (player = self._currentPlayer) {
@@ -2870,10 +2899,10 @@ export class PlaybackManager {
contentType = 'application/x-mpegURL';
} else {
contentType = getMimeType(type.toLowerCase(), mediaSource.TranscodingContainer);
+ }
- if (mediaUrl.toLowerCase().indexOf('copytimestamps=true') === -1) {
- transcodingOffsetTicks = startPosition || 0;
- }
+ if (!(mediaSource.TranscodingSubProtocol === 'hls' && hasSelectedExternalGraphicalSubtitle(mediaSource))) {
+ transcodingOffsetTicks = startPosition || 0;
}
}
} else {
diff --git a/src/components/subtitlesettings/subtitlesettings.js b/src/components/subtitlesettings/subtitlesettings.js
index f8d8aea9b1..0876449db0 100644
--- a/src/components/subtitlesettings/subtitlesettings.js
+++ b/src/components/subtitlesettings/subtitlesettings.js
@@ -28,6 +28,7 @@ import template from './subtitlesettings.template.html';
function getSubtitleAppearanceObject(context) {
return {
+ aspectMode: context.querySelector('#selectBitmapSubtitleAspectMode').value,
subtitleStyling: context.querySelector('#selectSubtitleStyling').value,
textSize: context.querySelector('#selectTextSize').value,
textWeight: context.querySelector('#selectTextWeight').value,
@@ -39,6 +40,15 @@ function getSubtitleAppearanceObject(context) {
};
}
+function toggleBitmapSubtitleAspectModeField(view) {
+ const fieldBitmapSubtitleAspectMode = view.querySelector('.fldBitmapSubtitleAspectMode');
+ const fieldRenderPgs = view.querySelector('.fldRenderPgs');
+ const renderPgsEnabled = view.querySelector('#chkSubtitleRenderPgs').checked;
+ const renderPgsVisible = !fieldRenderPgs.classList.contains('hide');
+
+ fieldBitmapSubtitleAspectMode.classList.toggle('hide', !renderPgsVisible || !renderPgsEnabled);
+}
+
function loadForm(context, user, userSettings, appearanceSettings, apiClient) {
apiClient.getCultures().then(function (allCultures) {
if (appHost.supports(AppFeature.SubtitleBurnIn) && user.Policy.EnableVideoPlaybackTranscoding) {
@@ -64,11 +74,13 @@ function loadForm(context, user, userSettings, appearanceSettings, apiClient) {
context.querySelector('#inputTextColor').value = appearanceSettings.textColor || '#ffffff';
context.querySelector('#selectFont').value = appearanceSettings.font || '';
context.querySelector('#sliderVerticalPosition').value = appearanceSettings.verticalPosition;
+ context.querySelector('#selectBitmapSubtitleAspectMode').value = appearanceSettings.aspectMode || 'stretch';
context.querySelector('#selectSubtitleBurnIn').value = appSettings.get('subtitleburnin') || '';
context.querySelector('#chkSubtitleRenderPgs').checked = appSettings.get('subtitlerenderpgs') === 'true';
context.querySelector('#selectSubtitleBurnIn').dispatchEvent(new CustomEvent('change', {}));
+ toggleBitmapSubtitleAspectModeField(context);
context.querySelector('#chkAlwaysBurnInSubtitleWhenTranscoding').checked = appSettings.alwaysBurnInSubtitleWhenTranscoding();
onAppearanceFieldChange({
@@ -138,6 +150,12 @@ function onSubtitleBurnInChange(e) {
// Pgs option is only available if burn-in mode is set to 'auto' (empty string)
fieldRenderPgs.classList.toggle('hide', !!this.value);
+ toggleBitmapSubtitleAspectModeField(view);
+}
+
+function onSubtitleRenderPgsChange(e) {
+ const view = dom.parentWithClass(e.target, 'subtitlesettings');
+ toggleBitmapSubtitleAspectModeField(view);
}
function onAppearanceFieldChange(e) {
@@ -197,10 +215,12 @@ function embed(options, self) {
options.element.querySelector('#selectSubtitlePlaybackMode').addEventListener('change', onSubtitleModeChange);
options.element.querySelector('#selectSubtitleStyling').addEventListener('change', onSubtitleStyleChange);
options.element.querySelector('#selectSubtitleBurnIn').addEventListener('change', onSubtitleBurnInChange);
+ options.element.querySelector('#chkSubtitleRenderPgs').addEventListener('change', onSubtitleRenderPgsChange);
options.element.querySelector('#selectTextSize').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectTextWeight').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectDropShadow').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectFont').addEventListener('change', onAppearanceFieldChange);
+ options.element.querySelector('#selectBitmapSubtitleAspectMode').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectTextColor').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#inputTextColor').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#inputTextBackground').addEventListener('change', onAppearanceFieldChange);
diff --git a/src/components/subtitlesettings/subtitlesettings.template.html b/src/components/subtitlesettings/subtitlesettings.template.html
index f2c774f00c..250b1801f1 100644
--- a/src/components/subtitlesettings/subtitlesettings.template.html
+++ b/src/components/subtitlesettings/subtitlesettings.template.html
@@ -40,6 +40,15 @@
${RenderPgsSubtitleHelp}
+
+
+
+
${BitmapSubtitleAspectModeHelp}
+
diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js
index 7c7b8f2602..8ecaafad3d 100644
--- a/src/plugins/htmlVideoPlayer/plugin.js
+++ b/src/plugins/htmlVideoPlayer/plugin.js
@@ -45,6 +45,11 @@ import Events from '../../utils/events.ts';
import { includesAny } from '../../utils/container.ts';
import { isHls } from '../../utils/mediaSource.ts';
+const NATIVE_UNSUPPORTED_SUBTITLE_CODECS = ['ssa', 'ass', 'pgssub', 'dvdsub', 'vobsub'];
+const ASS_SUBTITLE_CODECS = ['ssa', 'ass'];
+const VOBSUB_SUBTITLE_CODECS = ['dvdsub', 'vobsub'];
+const BITMAP_SUBTITLE_ASPECT_MODES = ['stretch', 'contain', 'cover'];
+
/**
* Returns resolved URL.
* @param {string} url - URL.
@@ -106,7 +111,7 @@ function enableNativeTrackSupport(mediaSource, track) {
if (track) {
const format = (track.Codec || '').toLowerCase();
- if (format === 'ssa' || format === 'ass' || format === 'pgssub') {
+ if (NATIVE_UNSUPPORTED_SUBTITLE_CODECS.includes(format)) {
return false;
}
}
@@ -175,11 +180,54 @@ function getTextTrackUrl(track, item, format) {
return url;
}
+function getSubtitleFileNameHint(track) {
+ const candidates = [track?.Path, track?.DeliveryUrl];
+ for (const candidate of candidates) {
+ if (!candidate) {
+ continue;
+ }
+
+ const sanitized = candidate.split(/[?#]/)[0];
+ const fileName = sanitized.split(/[\\/]/).pop();
+ if (fileName) {
+ return fileName;
+ }
+ }
+
+ const codec = (track?.Codec || '').toLowerCase();
+ if (VOBSUB_SUBTITLE_CODECS.includes(codec)) {
+ return 'subtitle.mks';
+ }
+
+ return undefined;
+}
+
+function getBitmapSubtitleDisplaySettings() {
+ const aspectMode = userSettings.getSubtitleAppearanceSettings()?.aspectMode;
+ const normalizedAspectMode = typeof aspectMode === 'string' ? aspectMode.toLowerCase() : 'stretch';
+
+ if (BITMAP_SUBTITLE_ASPECT_MODES.includes(normalizedAspectMode)) {
+ return {
+ aspectMode: normalizedAspectMode
+ };
+ }
+
+ return {
+ aspectMode: 'contain'
+ };
+}
+
+function getSubtitleTimeOffset(playOptions, subtitleOffset = 0) {
+ return ((playOptions?.transcodingOffsetTicks || 0) / 10000000) + subtitleOffset;
+}
+
function getDefaultProfile() {
return profileBuilder({});
}
const PRIMARY_TEXT_TRACK_INDEX = 0;
+const VOBSUB_DEBAND_THRESHOLD = 64;
+const VOBSUB_DEBAND_RANGE = 15;
const SECONDARY_TEXT_TRACK_INDEX = 1;
export class HtmlVideoPlayer {
@@ -228,7 +276,7 @@ export class HtmlVideoPlayer {
/**
* @type {any | null | undefined}
*/
- #currentPgsRenderer;
+ #currentBitmapSubRenderer;
/**
* @type {number | undefined}
*/
@@ -277,6 +325,10 @@ export class HtmlVideoPlayer {
* @type {number}
*/
#fetchQueue = 0;
+ /**
+ * @type {Map}
+ */
+ #pendingSubtitleLoads = new Map();
/**
* @type {string | undefined}
*/
@@ -356,6 +408,57 @@ export class HtmlVideoPlayer {
}
}
+ /**
+ * @private
+ */
+ beginPendingSubtitleLoad(targetTextTrackIndex, loadToken) {
+ const pendingLoad = this.#pendingSubtitleLoads.get(targetTextTrackIndex);
+ if (!pendingLoad || pendingLoad.token !== loadToken || pendingLoad.active) {
+ return;
+ }
+
+ pendingLoad.active = true;
+ this.incrementFetchQueue();
+ }
+
+ /**
+ * @private
+ */
+ endPendingSubtitleLoad(targetTextTrackIndex, loadToken) {
+ const pendingLoad = this.#pendingSubtitleLoads.get(targetTextTrackIndex);
+ if (!pendingLoad || (loadToken && pendingLoad.token !== loadToken)) {
+ return;
+ }
+
+ this.#pendingSubtitleLoads.delete(targetTextTrackIndex);
+ if (pendingLoad.active) {
+ this.decrementFetchQueue();
+ }
+ }
+
+ /**
+ * @private
+ */
+ createBitmapSubtitleRendererOptions(videoElement, track, item, targetTextTrackIndex) {
+ const loadToken = Symbol(String(targetTextTrackIndex));
+ const displaySettings = getBitmapSubtitleDisplaySettings();
+ this.endPendingSubtitleLoad(targetTextTrackIndex);
+ this.#pendingSubtitleLoads.set(targetTextTrackIndex, {
+ token: loadToken,
+ active: false
+ });
+
+ return {
+ video: videoElement,
+ subUrl: getTextTrackUrl(track, item),
+ timeOffset: getSubtitleTimeOffset(this._currentPlayOptions, this.#currentTrackOffset),
+ ...(displaySettings ? { displaySettings } : {}),
+ onLoading: () => this.beginPendingSubtitleLoad(targetTextTrackIndex, loadToken),
+ onLoaded: () => this.endPendingSubtitleLoad(targetTextTrackIndex, loadToken),
+ onError: () => this.endPendingSubtitleLoad(targetTextTrackIndex, loadToken)
+ };
+ }
+
/**
* @private
*/
@@ -606,10 +709,10 @@ export class HtmlVideoPlayer {
// if .ass currently rendering
if (this.#currentAssRenderer) {
this.updateCurrentTrackOffset(offsetValue);
- this.#currentAssRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
- } else if (this.#currentPgsRenderer) {
+ this.#currentAssRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, offsetValue);
+ } else if (this.#currentBitmapSubRenderer) {
this.updateCurrentTrackOffset(offsetValue);
- this.#currentPgsRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
+ this.#currentBitmapSubRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, offsetValue);
} else {
const trackElements = this.getTextTracks();
// if .vtt currently rendering
@@ -621,6 +724,7 @@ export class HtmlVideoPlayer {
this.#currentTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentTrackEvents, offsetValue, PRIMARY_TEXT_TRACK_INDEX);
this.#currentSecondaryTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentSecondaryTrackEvents, offsetValue, SECONDARY_TEXT_TRACK_INDEX);
} else {
+ this.updateCurrentTrackOffset(offsetValue);
console.debug('No available track, cannot apply offset: ', offsetValue);
}
}
@@ -999,10 +1103,15 @@ export class HtmlVideoPlayer {
seekOnPlaybackStart(this, e.target, this._currentPlayOptions.playerStartPositionTicks, () => {
if (this.#currentAssRenderer) {
- this.#currentAssRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + this.#currentTrackOffset;
+ this.#currentAssRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, this.#currentTrackOffset);
this.#currentAssRenderer.resize();
this.#currentAssRenderer.resetRenderAheadCache(false);
}
+
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, this.#currentTrackOffset);
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
+ }
});
if (this._currentPlayOptions.fullscreen) {
@@ -1181,6 +1290,13 @@ export class HtmlVideoPlayer {
* @private
*/
destroyCustomTrack(videoElement, targetTrackIndex) {
+ if (targetTrackIndex === undefined) {
+ this.endPendingSubtitleLoad(PRIMARY_TEXT_TRACK_INDEX);
+ this.endPendingSubtitleLoad(SECONDARY_TEXT_TRACK_INDEX);
+ } else {
+ this.endPendingSubtitleLoad(targetTrackIndex);
+ }
+
this.destroyCustomRenderedTrackElements(targetTrackIndex);
this.destroyNativeTracks(videoElement, targetTrackIndex);
this.destroyStoredTrackInfo(targetTrackIndex);
@@ -1191,11 +1307,11 @@ export class HtmlVideoPlayer {
}
this.#currentAssRenderer = null;
- const pgsRenderer = this.#currentPgsRenderer;
- if (pgsRenderer) {
- pgsRenderer.dispose();
+ const pgsOrVobSubRenderer = this.#currentBitmapSubRenderer;
+ if (pgsOrVobSubRenderer) {
+ pgsOrVobSubRenderer.dispose();
}
- this.#currentPgsRenderer = null;
+ this.#currentBitmapSubRenderer = null;
}
/**
@@ -1346,17 +1462,78 @@ export class HtmlVideoPlayer {
/**
* @private
*/
- renderPgs(videoElement, track, item) {
- import('libpgs').then((libpgs) => {
- const aspectRatio = this.getAspectRatio() === 'auto' ? 'contain' : this.getAspectRatio();
- const options = {
- video: videoElement,
- subUrl: getTextTrackUrl(track, item),
- workerUrl: `${appRouter.baseUrl()}/libraries/libpgs.worker.js`,
- timeOffset: (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000,
- aspectRatio
- };
- this.#currentPgsRenderer = new libpgs.PgsRenderer(options);
+ renderPgs(videoElement, track, item, targetTextTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
+ const options = this.createBitmapSubtitleRendererOptions(videoElement, track, item, targetTextTrackIndex);
+ const onLoaded = options.onLoaded;
+ const onError = options.onError;
+ options.onLoaded = () => {
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, this.#currentTrackOffset);
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
+ }
+ onLoaded?.();
+ };
+ options.onError = (error) => {
+ console.error('[libbitsub] pgs error', error);
+ onError?.(error);
+ };
+ options.onEvent = (event) => {
+ if (event?.type === 'error' || event?.type === 'loaded' || event?.type === 'cue-change' || event?.type === 'renderer-change' || event?.type === 'worker-state') {
+ console.debug('[libbitsub] pgs', event);
+ }
+ };
+ import('libbitsub').then((libbitsub) => {
+ this.#currentBitmapSubRenderer = new libbitsub.PgsRenderer(options);
+ requestAnimationFrame(() => {
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
+ }
+ });
+ }).catch((error) => {
+ this.endPendingSubtitleLoad(targetTextTrackIndex);
+ console.error(error);
+ });
+ }
+
+ /**
+ * @private
+ */
+ renderVobSub(videoElement, track, item, targetTextTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
+ const options = {
+ ...this.createBitmapSubtitleRendererOptions(videoElement, track, item, targetTextTrackIndex),
+ fileName: getSubtitleFileNameHint(track)
+ };
+ const onLoaded = options.onLoaded;
+ const onError = options.onError;
+ options.onLoaded = () => {
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.timeOffset = getSubtitleTimeOffset(this._currentPlayOptions, this.#currentTrackOffset);
+ this.#currentBitmapSubRenderer.setDebandEnabled?.(true);
+ this.#currentBitmapSubRenderer.setDebandThreshold?.(VOBSUB_DEBAND_THRESHOLD);
+ this.#currentBitmapSubRenderer.setDebandRange?.(VOBSUB_DEBAND_RANGE);
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
+ }
+ onLoaded?.();
+ };
+ options.onError = (error) => {
+ console.error('[libbitsub] vobsub error', error);
+ onError?.(error);
+ };
+ options.onEvent = (event) => {
+ if (event?.type === 'error' || event?.type === 'loaded' || event?.type === 'cue-change' || event?.type === 'renderer-change' || event?.type === 'worker-state') {
+ console.debug('[libbitsub] vobsub', event);
+ }
+ };
+ import('libbitsub').then((libbitsub) => {
+ this.#currentBitmapSubRenderer = new libbitsub.VobSubRenderer(options);
+ requestAnimationFrame(() => {
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
+ }
+ });
+ }).catch((error) => {
+ this.endPendingSubtitleLoad(targetTextTrackIndex);
+ console.error(error);
});
}
@@ -1443,12 +1620,16 @@ export class HtmlVideoPlayer {
async renderTracksEvents(videoElement, track, item, targetTextTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
if (!itemHelper.isLocalItem(item) || track.IsExternal) {
const format = (track.Codec || '').toLowerCase();
- if (format === 'ssa' || format === 'ass') {
+ if (ASS_SUBTITLE_CODECS.includes(format)) {
this.renderSsaAss(videoElement, track, item);
return;
}
if (format === 'pgssub') {
- this.renderPgs(videoElement, track, item);
+ this.renderPgs(videoElement, track, item, targetTextTrackIndex);
+ return;
+ }
+ if (VOBSUB_SUBTITLE_CODECS.includes(format)) {
+ this.renderVobSub(videoElement, track, item, targetTextTrackIndex);
return;
}
@@ -2077,8 +2258,8 @@ export class HtmlVideoPlayer {
}
}
- if (this.#currentPgsRenderer) {
- this.#currentPgsRenderer.aspectRatio = val === 'auto' ? 'contain' : val;
+ if (this.#currentBitmapSubRenderer) {
+ this.#currentBitmapSubRenderer.updateCanvasSize?.();
}
}
diff --git a/src/scripts/browserDeviceProfile.js b/src/scripts/browserDeviceProfile.js
index c92757f5bd..3503024f42 100644
--- a/src/scripts/browserDeviceProfile.js
+++ b/src/scripts/browserDeviceProfile.js
@@ -1617,6 +1617,11 @@ export default function (options) {
Format: 'pgssub',
Method: 'External'
});
+ profile.SubtitleProfiles.push({
+ Format: 'vobsub',
+ Container: 'mks',
+ Method: 'External'
+ });
}
}
diff --git a/src/scripts/settings/userSettings.js b/src/scripts/settings/userSettings.js
index 814ccbd5df..ec7eb179dd 100644
--- a/src/scripts/settings/userSettings.js
+++ b/src/scripts/settings/userSettings.js
@@ -49,7 +49,8 @@ function filterQuerySettings(query, allowedItems) {
}
const defaultSubtitleAppearanceSettings = {
- verticalPosition: -3
+ verticalPosition: -3,
+ aspectMode: 'contain'
};
const defaultComicsPlayerSettings = {
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index 693468f439..12e5ea5ab5 100755
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -83,6 +83,10 @@
"BirthDateValue": "Born: {0}",
"BirthLocation": "Birth location",
"BirthPlaceValue": "Birth place: {0}",
+ "BitmapSubtitleAspectModeContain": "Contain",
+ "BitmapSubtitleAspectModeCover": "Cover",
+ "BitmapSubtitleAspectModeHelp": "Controls how client-rendered PGS and VobSub subtitles are scaled to the visible video area.",
+ "BitmapSubtitleAspectModeStretch": "Stretch",
"Blacklist": "Blacklist",
"BlockContentWithTagsHelp": "Hide media with at least one of the specified tags.",
"BookLibraryHelp": "Audio and text books are supported. Review the {0} book naming guide {1}.",
@@ -670,6 +674,7 @@
"LabelBlockContentWithTags": "Block items with tags",
"LabelBuildVersion": "Build version",
"LabelBundled": "Bundled",
+ "LabelBitmapSubtitleAspectMode": "Bitmap subtitle aspect mode",
"LabelBurnSubtitles": "Burn subtitles",
"LabelCache": "Cache",
"LabelCachePath": "Cache path",
@@ -1515,8 +1520,8 @@
"Remixer": "Remixer",
"RemoveFromCollection": "Remove from collection",
"RemoveFromPlaylist": "Remove from playlist",
- "RenderPgsSubtitle": "Experimental PGS subtitle rendering",
- "RenderPgsSubtitleHelp": "Determine if the client should render PGS subtitles instead of using burned in subtitles. This can avoid server-side transcoding in exchange of client-side rendering performance.",
+ "RenderPgsSubtitle": "Experimental PGS and VobSub subtitle rendering",
+ "RenderPgsSubtitleHelp": "Determine if the client should render PGS and VobSub subtitles instead of using burned in subtitles. This can avoid server-side transcoding in exchange of client-side rendering performance.",
"Repeat": "Repeat",
"RepeatAll": "Repeat all",
"RepeatEpisodes": "Repeat episodes",
diff --git a/webpack.common.js b/webpack.common.js
index 9ac83db382..acf1971a1e 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -16,8 +16,7 @@ const Assets = [
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.js',
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.wasm',
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker-legacy.js',
- 'pdfjs-dist/build/pdf.worker.js',
- 'libpgs/dist/libpgs.worker.js'
+ 'pdfjs-dist/build/pdf.worker.js'
];
const DEV_MODE = process.env.NODE_ENV !== 'production';
@@ -227,6 +226,7 @@ const config = {
path.resolve(__dirname, 'node_modules/highlight-words'),
path.resolve(__dirname, 'node_modules/idb-keyval'),
path.resolve(__dirname, 'node_modules/libarchive.js'),
+ path.resolve(__dirname, 'node_modules/libbitsub'),
path.resolve(__dirname, 'node_modules/linkify-it'),
path.resolve(__dirname, 'node_modules/markdown-it'),
path.resolve(__dirname, 'node_modules/material-react-table'),