From 145aea184fb6746d551da90a88831e201330deb9 Mon Sep 17 00:00:00 2001 From: Ivan Schurawel Date: Thu, 15 Sep 2022 14:22:41 -0400 Subject: [PATCH] feat: add native secondary subtitle support --- src/components/playback/playbackmanager.js | 67 ++++++++++++++++++++ src/controllers/playback/video/index.js | 73 +++++++++++++++++++++- src/plugins/htmlVideoPlayer/plugin.js | 71 ++++++++++++++++----- src/strings/en-us.json | 1 + 4 files changed, 192 insertions(+), 20 deletions(-) diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 0013eb654..48e291318 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -876,6 +876,17 @@ class PlaybackManager { }); }; + self.hasSecondarySubtitleSupport = function (player = self._currentPlayer) { + if (!player) return false; + return Boolean(player.supports('SecondarySubtitles')); + }; + + self.secondarySubtitleTracks = function (player = self._currentPlayer) { + const streams = self.subtitleTracks(player); + // Currently, only External subtitles are supported + return streams.filter((stream) => getDeliveryMethod(stream) === 'External'); + }; + function getCurrentSubtitleStream(player) { if (!player) { throw new Error('player cannot be null'); @@ -890,6 +901,20 @@ class PlaybackManager { return getSubtitleStream(player, index); } + function getCurrentSecondarySubtitleStream(player) { + if (!player) { + throw new Error('player cannot be null'); + } + + const index = getPlayerData(player).secondarySubtitleStreamIndex; + + if (index == null || index === -1) { + return null; + } + + return getSubtitleStream(player, index); + } + function getSubtitleStream(player, index) { return self.subtitleTracks(player).filter(function (s) { return s.Type === 'Subtitle' && s.Index === index; @@ -1522,9 +1547,51 @@ class PlaybackManager { player.setSubtitleStreamIndex(selectedTrackElementIndex); + // Also disable secondary subtitles when disabling the primary subtitles + if (selectedTrackElementIndex === -1) { + self.setSecondarySubtitleStreamIndex(selectedTrackElementIndex); + } + getPlayerData(player).subtitleStreamIndex = index; }; + self.setSecondarySubtitleStreamIndex = function (index, player) { + player = player || self._currentPlayer; + if (!self.hasSecondarySubtitleSupport(player)) return; + if (player && !enableLocalPlaylistManagement(player)) { + try { + return player.setSecondarySubtitleStreamIndex(index); + } catch (e) { + console.error(`AutoSet - Failed to set secondary track: ${e}`); + } + } + + const currentStream = getCurrentSecondarySubtitleStream(player); + + const newStream = getSubtitleStream(player, index); + + if (!currentStream && !newStream) { + return; + } + + const clearingStream = currentStream && !newStream; + const changingStream = currentStream && newStream; + const addingStream = !currentStream && newStream; + // Secondary subtitles are currently only handled client side + // Changes to the server code are required before we can handle other delivery methods + if (!clearingStream && (changingStream || addingStream) && getDeliveryMethod(newStream) !== 'External') { + return; + } + + getPlayerData(player).secondarySubtitleStreamIndex = index; + + try { + player.setSecondarySubtitleStreamIndex(index); + } catch (e) { + console.error(`AutoSet - Failed to set secondary track: ${e}`); + } + }; + self.supportSubtitleOffset = function (player) { player = player || self._currentPlayer; return player && 'setSubtitleOffset' in player; diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index 8fc697659..6ca0f59b4 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -988,9 +988,57 @@ import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../../components }); } + function showSecondarySubtitlesMenu(actionsheet, positionTo) { + const player = currentPlayer; + if (!playbackManager.hasSecondarySubtitleSupport(player)) return; + let currentIndex = playbackManager.getSecondarySubtitleStreamIndex(player); + const streams = playbackManager.secondarySubtitleTracks(player); + + if (currentIndex == null) { + currentIndex = -1; + } + + streams.unshift({ + Index: -1, + DisplayTitle: globalize.translate('Off') + }); + + const menuItems = streams.map(function (stream) { + const opt = { + name: stream.DisplayTitle, + id: stream.Index + }; + + if (stream.Index === currentIndex) { + opt.selected = true; + } + + return opt; + }); + + actionsheet.show({ + title: globalize.translate('SecondarySubtitles'), + items: menuItems, + positionTo + }).then(function (id) { + if (id) { + const index = parseInt(id); + if (index !== currentIndex) { + playbackManager.setSecondarySubtitleStreamIndex(index, player); + } + } + }) + .finally(() => { + resetIdle(); + }); + + setTimeout(resetIdle, 0); + } + function showSubtitleTrackSelection() { const player = currentPlayer; const streams = playbackManager.subtitleTracks(player); + const secondaryStreams = playbackManager.secondarySubtitleTracks(player); let currentIndex = playbackManager.getSubtitleStreamIndex(player); if (currentIndex == null) { @@ -1013,18 +1061,37 @@ import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../../components return opt; }); + + // Only show option if: player has support, has more than 1 subtitle track, has valid secondary tracks, primary subtitle is not off + if (playbackManager.hasSecondarySubtitleSupport(player) && streams.length > 1 && secondaryStreams.length > 0 && currentIndex !== -1) { + const secondarySubtitleMenuItem = { + name: globalize.translate('SecondarySubtitles'), + id: 'secondarysubtitle' + }; + menuItems.unshift(secondarySubtitleMenuItem); + } + const positionTo = this; import('../../../components/actionSheet/actionSheet').then(({default: actionsheet}) => { actionsheet.show({ title: globalize.translate('Subtitles'), items: menuItems, + resolveOnClick: true, positionTo: positionTo }).then(function (id) { - const index = parseInt(id); + if (id === 'secondarysubtitle') { + try { + showSecondarySubtitlesMenu(actionsheet, positionTo); + } catch (e) { + console.error(e); + } + } else { + const index = parseInt(id); - if (index !== currentIndex) { - playbackManager.setSubtitleStreamIndex(index, player); + if (index !== currentIndex) { + playbackManager.setSubtitleStreamIndex(index, player); + } } toggleSubtitleSync(); diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js index af136e918..2222548a7 100644 --- a/src/plugins/htmlVideoPlayer/plugin.js +++ b/src/plugins/htmlVideoPlayer/plugin.js @@ -178,7 +178,6 @@ function tryRemoveElement(elem) { * @type {boolean} */ isFetching = false; - /** * @type {HTMLDivElement | null | undefined} */ @@ -207,6 +206,10 @@ function tryRemoveElement(elem) { * @type {number | undefined} */ #customTrackIndex; + /** + * @type {number | undefined} + */ + #customSecondaryTrackIndex; /** * @type {boolean | undefined} */ @@ -270,6 +273,14 @@ function tryRemoveElement(elem) { * @type {any | undefined} */ _currentPlayOptions; + /** + * @type {number} + */ + _PRIMARY_TEXT_TRACK_INDEX = 0; + /** + * @type {number} + */ + _SECONDARY_TEXT_TRACK_INDEX = 1; /** * @type {any | undefined} */ @@ -490,6 +501,10 @@ function tryRemoveElement(elem) { this.setCurrentTrackElement(index); } + setSecondarySubtitleStreamIndex(index) { + this.setCurrentTrackElement(index, this._SECONDARY_TEXT_TRACK_INDEX); + } + resetSubtitleOffset() { this.#currentTrackOffset = 0; this.#showTrackOffset = false; @@ -514,7 +529,7 @@ function tryRemoveElement(elem) { const videoElement = this.#mediaElement; if (videoElement) { return Array.from(videoElement.textTracks) - .find(function (trackElement) { + .filter(function (trackElement) { // get showing .vtt textTack return trackElement.mode === 'showing'; }); @@ -591,6 +606,10 @@ function tryRemoveElement(elem) { return this.#currentTrackOffset; } + isSecondaryTrack(textTrackIndex) { + return textTrackIndex === this._SECONDARY_TEXT_TRACK_INDEX; + } + /** * @private */ @@ -956,7 +975,9 @@ function tryRemoveElement(elem) { /** * @private */ - destroyCustomTrack(videoElement) { + destroyCustomTrack(videoElement, targetTrackIndex) { + const destroySingleTrack = typeof targetTrackIndex === 'number'; + if (this.#videoSubtitlesElem) { const subtitlesContainer = this.#videoSubtitlesElem.parentNode; if (subtitlesContainer) { @@ -969,7 +990,11 @@ function tryRemoveElement(elem) { if (videoElement) { const allTracks = videoElement.textTracks || []; // get list of tracks - for (const track of allTracks) { + for (let index = 0; index < allTracks.length; index++) { + const track = allTracks[index]; + if (destroySingleTrack && targetTrackIndex !== index) { + continue; + } if (track.label.includes('manualTrack')) { track.mode = 'disabled'; } @@ -1029,23 +1054,34 @@ function tryRemoveElement(elem) { /** * @private */ - setTrackForDisplay(videoElement, track) { + setTrackForDisplay(videoElement, track, targetTextTrackIndex = this._PRIMARY_TEXT_TRACK_INDEX) { if (!track) { - this.destroyCustomTrack(videoElement); + // Destroy all tracks by passing undefined if there is no valid primary track + this.destroyCustomTrack(videoElement, this.isSecondaryTrack(targetTextTrackIndex) ? targetTextTrackIndex : undefined); return; } + let targetTrackIndex = this.#customTrackIndex; + if (this.isSecondaryTrack(targetTextTrackIndex)) { + targetTrackIndex = this.#customSecondaryTrackIndex; + } + // skip if already playing this track - if (this.#customTrackIndex === track.Index) { + if (targetTrackIndex === track.Index) { return; } this.resetSubtitleOffset(); const item = this._currentPlayOptions.item; - this.destroyCustomTrack(videoElement); - this.#customTrackIndex = track.Index; - this.renderTracksEvents(videoElement, track, item); + this.destroyCustomTrack(videoElement, targetTextTrackIndex); + + if (this.isSecondaryTrack(targetTextTrackIndex)) { + this.#customSecondaryTrackIndex = track.Index; + } else { + this.#customTrackIndex = track.Index; + } + this.renderTracksEvents(videoElement, track, item, targetTextTrackIndex); } /** @@ -1211,7 +1247,7 @@ function tryRemoveElement(elem) { /** * @private */ - renderTracksEvents(videoElement, track, item) { + renderTracksEvents(videoElement, track, item, targetTextTrackIndex = this._PRIMARY_TEXT_TRACK_INDEX) { if (!itemHelper.isLocalItem(item) || track.IsExternal) { const format = (track.Codec || '').toLowerCase(); if (format === 'ssa' || format === 'ass') { @@ -1220,15 +1256,15 @@ function tryRemoveElement(elem) { } if (this.requiresCustomSubtitlesElement()) { - this.renderSubtitlesWithCustomElement(videoElement, track, item); + this.renderSubtitlesWithCustomElement(videoElement, track, item, targetTextTrackIndex); return; } } let trackElement = null; - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - trackElement = videoElement.textTracks[0]; - + const updatingTrack = videoElement.textTracks && videoElement.textTracks.length > (this.isSecondaryTrack(targetTextTrackIndex) ? 1 : 0); + if (updatingTrack) { + trackElement = videoElement.textTracks[targetTextTrackIndex]; // This throws an error in IE, but is fine in chrome // In IE it's not necessary anyway because changing the src seems to be enough try { @@ -1313,7 +1349,7 @@ function tryRemoveElement(elem) { /** * @private */ - setCurrentTrackElement(streamIndex) { + setCurrentTrackElement(streamIndex, targetTextTrackIndex) { console.debug(`setting new text track index to: ${streamIndex}`); const mediaStreamTextTracks = getMediaStreamTextTracks(this._currentPlayOptions.mediaSource); @@ -1322,7 +1358,7 @@ function tryRemoveElement(elem) { return t.Index === streamIndex; })[0]; - this.setTrackForDisplay(this.#mediaElement, track); + this.setTrackForDisplay(this.#mediaElement, track, targetTextTrackIndex); if (enableNativeTrackSupport(this.#currentSrc, track)) { if (streamIndex !== -1) { this.setCueAppearance(); @@ -1500,6 +1536,7 @@ function tryRemoveElement(elem) { list.push('SetBrightness'); list.push('SetAspectRatio'); + list.push('SecondarySubtitles'); return list; } diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 50567aada..5ca842e8f 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1412,6 +1412,7 @@ "SearchForSubtitles": "Search for Subtitles", "SearchResults": "Search Results", "Season": "Season", + "SecondarySubtitles": "Secondary Subtitles", "SelectAdminUsername": "Please select a username for the admin account.", "SelectServer": "Select Server", "SendMessage": "Send message",