diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 0013eb654..2b8bd2128 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -422,7 +422,8 @@ function getPlaybackInfo(player, enableDirectPlay, enableDirectStream, allowVideoStreamCopy, - allowAudioStreamCopy) { + allowAudioStreamCopy, + secondarySubtitleStreamIndex) { if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio' && !player.useServerPlaybackInfoForAudio) { return Promise.resolve({ MediaSources: [ @@ -462,6 +463,9 @@ function getPlaybackInfo(player, if (subtitleStreamIndex != null) { query.SubtitleStreamIndex = subtitleStreamIndex; } + if (secondarySubtitleStreamIndex != null) { + query.SecondarySubtitleStreamIndex = secondarySubtitleStreamIndex; + } if (enableDirectPlay != null) { query.EnableDirectPlay = enableDirectPlay; } @@ -876,25 +880,49 @@ class PlaybackManager { }); }; - function getCurrentSubtitleStream(player) { + self.playerHasSecondarySubtitleSupport = function (player = self._currentPlayer) { + if (!player) return false; + return Boolean(player.supports('SecondarySubtitles')); + }; + + /** + * Checks if: + * - the track can be used directly as a secondary subtitle + * - or if it can be paired with a secondary subtitle when used as a primary subtitle + */ + 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'; + }; + + self.secondarySubtitleTracks = function (player = self._currentPlayer) { + const streams = self.subtitleTracks(player); + return streams.filter((stream) => self.trackHasSecondarySubtitleSupport(stream, player)); + }; + + function getCurrentSubtitleStream(player, isSecondaryStream = false) { if (!player) { throw new Error('player cannot be null'); } - const index = getPlayerData(player).subtitleStreamIndex; + const index = isSecondaryStream ? getPlayerData(player).secondarySubtitleStreamIndex : getPlayerData(player).subtitleStreamIndex; if (index == null || index === -1) { return null; } - return getSubtitleStream(player, index); + return self.getSubtitleStream(player, index); } - function getSubtitleStream(player, index) { + self.getSubtitleStream = function (player, index) { return self.subtitleTracks(player).filter(function (s) { return s.Type === 'Subtitle' && s.Index === index; })[0]; - } + }; self.getPlaylist = function (player) { player = player || self._currentPlayer; @@ -1463,6 +1491,24 @@ class PlaybackManager { return getPlayerData(player).subtitleStreamIndex; }; + self.getSecondarySubtitleStreamIndex = function (player) { + player = player || self._currentPlayer; + + if (!player) { + throw new Error('player cannot be null'); + } + + try { + if (!enableLocalPlaylistManagement(player)) { + return player.getSecondarySubtitleStreamIndex(); + } + } catch (e) { + console.error('[playbackmanager] Failed to get secondary stream index:', e); + } + + return getPlayerData(player).secondarySubtitleStreamIndex; + }; + function getDeliveryMethod(subtitleStream) { // This will be null for internal subs for local items if (subtitleStream.DeliveryMethod) { @@ -1480,7 +1526,7 @@ class PlaybackManager { const currentStream = getCurrentSubtitleStream(player); - const newStream = getSubtitleStream(player, index); + const newStream = self.getSubtitleStream(player, index); if (!currentStream && !newStream) { return; @@ -1522,9 +1568,48 @@ class PlaybackManager { player.setSubtitleStreamIndex(selectedTrackElementIndex); + // Also disable secondary subtitles when disabling the primary + // subtitles, or if it doesn't support a secondary pair + if (selectedTrackElementIndex === -1 || !self.trackHasSecondarySubtitleSupport(newStream)) { + self.setSecondarySubtitleStreamIndex(-1); + } + getPlayerData(player).subtitleStreamIndex = index; }; + self.setSecondarySubtitleStreamIndex = function (index, player) { + player = player || self._currentPlayer; + if (!self.playerHasSecondarySubtitleSupport(player)) return; + if (player && !enableLocalPlaylistManagement(player)) { + try { + return player.setSecondarySubtitleStreamIndex(index); + } catch (e) { + console.error('[playbackmanager] AutoSet - Failed to set secondary track:', e); + } + } + + const currentStream = getCurrentSubtitleStream(player, true); + + const newStream = self.getSubtitleStream(player, index); + + if (!currentStream && !newStream) { + return; + } + + // Secondary subtitles are currently only handled client side + // Changes to the server code are required before we can handle other delivery methods + if (newStream && !self.trackHasSecondarySubtitleSupport(newStream, player)) { + return; + } + + try { + player.setSecondarySubtitleStreamIndex(index); + getPlayerData(player).secondarySubtitleStreamIndex = index; + } catch (e) { + console.error('[playbackmanager] AutoSet - Failed to set secondary track:', e); + } + }; + self.supportSubtitleOffset = function (player) { player = player || self._currentPlayer; return player && 'setSubtitleOffset' in player; @@ -1548,7 +1633,7 @@ class PlaybackManager { }; self.isSubtitleStreamExternal = function (index, player) { - const stream = getSubtitleStream(player, index); + const stream = self.getSubtitleStream(player, index); return stream ? getDeliveryMethod(stream) === 'External' : false; }; @@ -1639,6 +1724,7 @@ class PlaybackManager { }).then(function (deviceProfile) { const audioStreamIndex = params.AudioStreamIndex == null ? getPlayerData(player).audioStreamIndex : params.AudioStreamIndex; const subtitleStreamIndex = params.SubtitleStreamIndex == null ? getPlayerData(player).subtitleStreamIndex : params.SubtitleStreamIndex; + const secondarySubtitleStreamIndex = params.SecondarySubtitleStreamIndex == null ? getPlayerData(player).secondarySubtitleStreamIndex : params.SecondarySubtitleStreamIndex; let currentMediaSource = self.currentMediaSource(player); const apiClient = ServerConnections.getApiClient(currentItem.ServerId); @@ -1665,6 +1751,7 @@ class PlaybackManager { } getPlayerData(player).subtitleStreamIndex = subtitleStreamIndex; + getPlayerData(player).secondarySubtitleStreamIndex = secondarySubtitleStreamIndex; getPlayerData(player).audioStreamIndex = audioStreamIndex; getPlayerData(player).maxStreamingBitrate = maxBitrate; @@ -1950,6 +2037,7 @@ class PlaybackManager { state.PlayState.PlaybackRate = self.getPlaybackRate(player); state.PlayState.SubtitleStreamIndex = self.getSubtitleStreamIndex(player); + state.PlayState.SecondarySubtitleStreamIndex = self.getSecondarySubtitleStreamIndex(player); state.PlayState.AudioStreamIndex = self.getAudioStreamIndex(player); state.PlayState.BufferedRanges = self.getBufferedRanges(player); @@ -2230,11 +2318,16 @@ class PlaybackManager { }); } - function rankStreamType(prevIndex, prevSource, mediaSource, streamType) { + function rankStreamType(prevIndex, prevSource, mediaSource, streamType, isSecondarySubtitle) { if (prevIndex == -1) { console.debug(`AutoSet ${streamType} - No Stream Set`); - if (streamType == 'Subtitle') - mediaSource.DefaultSubtitleStreamIndex = -1; + if (streamType == 'Subtitle') { + if (isSecondarySubtitle) { + mediaSource.DefaultSecondarySubtitleStreamIndex = -1; + } else { + mediaSource.DefaultSubtitleStreamIndex = -1; + } + } return; } @@ -2292,8 +2385,13 @@ class PlaybackManager { if (bestStreamIndex != null) { console.debug(`AutoSet ${streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`); - if (streamType == 'Subtitle') - mediaSource.DefaultSubtitleStreamIndex = bestStreamIndex; + if (streamType == 'Subtitle') { + if (isSecondarySubtitle) { + mediaSource.DefaultSecondarySubtitleStreamIndex = bestStreamIndex; + } else { + mediaSource.DefaultSubtitleStreamIndex = bestStreamIndex; + } + } if (streamType == 'Audio') mediaSource.DefaultAudioStreamIndex = bestStreamIndex; } else { @@ -2317,6 +2415,10 @@ class PlaybackManager { if (subtitle && typeof prevSource.DefaultSubtitleStreamIndex == 'number') { rankStreamType(prevSource.DefaultSubtitleStreamIndex, prevSource, mediaSource, 'Subtitle'); } + + if (subtitle && typeof prevSource.DefaultSecondarySubtitleStreamIndex == 'number') { + rankStreamType(prevSource.DefaultSecondarySubtitleStreamIndex, prevSource, mediaSource, 'Subtitle', true); + } } catch (e) { console.error(`AutoSet - Caught unexpected error: ${e}`); } @@ -2384,6 +2486,19 @@ class PlaybackManager { const user = await apiClient.getCurrentUser(); autoSetNextTracks(prevSource, mediaSource, user.Configuration.RememberAudioSelections, user.Configuration.RememberSubtitleSelections); + if (mediaSource.DefaultSubtitleStreamIndex == null || mediaSource.DefaultSubtitleStreamIndex < 0) { + mediaSource.DefaultSubtitleStreamIndex = mediaSource.DefaultSecondarySubtitleStreamIndex; + mediaSource.DefaultSecondarySubtitleStreamIndex = -1; + } + + const subtitleTrack1 = mediaSource.MediaStreams[mediaSource.DefaultSubtitleStreamIndex]; + const subtitleTrack2 = mediaSource.MediaStreams[mediaSource.DefaultSecondarySubtitleStreamIndex]; + + if (!self.trackHasSecondarySubtitleSupport(subtitleTrack1, player) + || !self.trackHasSecondarySubtitleSupport(subtitleTrack2, player)) { + mediaSource.DefaultSecondarySubtitleStreamIndex = -1; + } + const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player); streamInfo.fullscreen = playOptions.fullscreen; @@ -2751,7 +2866,8 @@ class PlaybackManager { return { ...prevSource, DefaultAudioStreamIndex: prevPlayerData.audioStreamIndex, - DefaultSubtitleStreamIndex: prevPlayerData.subtitleStreamIndex + DefaultSubtitleStreamIndex: prevPlayerData.subtitleStreamIndex, + DefaultSecondarySubtitleStreamIndex: prevPlayerData.secondarySubtitleStreamIndex }; } @@ -2910,9 +3026,11 @@ class PlaybackManager { if (mediaSource) { playerData.audioStreamIndex = mediaSource.DefaultAudioStreamIndex; playerData.subtitleStreamIndex = mediaSource.DefaultSubtitleStreamIndex; + playerData.secondarySubtitleStreamIndex = mediaSource.DefaultSecondarySubtitleStreamIndex; } else { playerData.audioStreamIndex = null; playerData.subtitleStreamIndex = null; + playerData.secondarySubtitleStreamIndex = null; } self._playNextAfterEnded = true; diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index 8fc697659..19c1492d0 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.playerHasSecondarySubtitleSupport(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,6 +1061,29 @@ 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 + * - primary subtitle has support + */ + const currentTrackCanAddSecondarySubtitle = playbackManager.playerHasSecondarySubtitleSupport(player) + && streams.length > 1 + && secondaryStreams.length > 0 + && currentIndex !== -1 + && playbackManager.trackHasSecondarySubtitleSupport(playbackManager.getSubtitleStream(player, currentIndex), player); + + if (currentTrackCanAddSecondarySubtitle) { + const secondarySubtitleMenuItem = { + name: globalize.translate('SecondarySubtitles'), + id: 'secondarysubtitle' + }; + menuItems.unshift(secondarySubtitleMenuItem); + } + const positionTo = this; import('../../../components/actionSheet/actionSheet').then(({default: actionsheet}) => { @@ -1021,10 +1092,18 @@ import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../../components items: menuItems, 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..f2a2b8d30 100644 --- a/src/plugins/htmlVideoPlayer/plugin.js +++ b/src/plugins/htmlVideoPlayer/plugin.js @@ -155,6 +155,9 @@ function tryRemoveElement(elem) { return profileBuilder({}); } + const PRIMARY_TEXT_TRACK_INDEX = 0; + const SECONDARY_TEXT_TRACK_INDEX = 1; + export class HtmlVideoPlayer { /** * @type {string} @@ -178,7 +181,6 @@ function tryRemoveElement(elem) { * @type {boolean} */ isFetching = false; - /** * @type {HTMLDivElement | null | undefined} */ @@ -187,6 +189,10 @@ function tryRemoveElement(elem) { * @type {number | undefined} */ #subtitleTrackIndexToSetOnPlaying; + /** + * @type {number | undefined} + */ + #secondarySubtitleTrackIndexToSetOnPlaying; /** * @type {number | null} */ @@ -207,6 +213,10 @@ function tryRemoveElement(elem) { * @type {number | undefined} */ #customTrackIndex; + /** + * @type {number | undefined} + */ + #customSecondaryTrackIndex; /** * @type {boolean | undefined} */ @@ -215,14 +225,26 @@ function tryRemoveElement(elem) { * @type {number | undefined} */ #currentTrackOffset; + /** + * @type {HTMLElement | null | undefined} + */ + #secondaryTrackOffset; /** * @type {HTMLElement | null | undefined} */ #videoSubtitlesElem; + /** + * @type {HTMLElement | null | undefined} + */ + #videoSecondarySubtitlesElem; /** * @type {any | null | undefined} */ #currentTrackEvents; + /** + * @type {any | null | undefined} + */ + #currentSecondaryTrackEvents; /** * @type {string[] | undefined} */ @@ -448,18 +470,39 @@ function tryRemoveElement(elem) { destroyFlvPlayer(this); destroyCastPlayer(this); + let secondaryTrackValid = true; + this.#subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex; if (this.#subtitleTrackIndexToSetOnPlaying != null && this.#subtitleTrackIndexToSetOnPlaying >= 0) { const initialSubtitleStream = options.mediaSource.MediaStreams[this.#subtitleTrackIndexToSetOnPlaying]; if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') { this.#subtitleTrackIndexToSetOnPlaying = -1; + secondaryTrackValid = false; } + // secondary track should not be shown if primary track is no longer a valid pair + if (initialSubtitleStream && !playbackManager.trackHasSecondarySubtitleSupport(initialSubtitleStream, this)) { + secondaryTrackValid = false; + } + } else { + secondaryTrackValid = false; } this.#audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex; this._currentPlayOptions = options; + if (secondaryTrackValid) { + this.#secondarySubtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSecondarySubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSecondarySubtitleStreamIndex; + if (this.#secondarySubtitleTrackIndexToSetOnPlaying != null && this.#secondarySubtitleTrackIndexToSetOnPlaying >= 0) { + const initialSecondarySubtitleStream = options.mediaSource.MediaStreams[this.#secondarySubtitleTrackIndexToSetOnPlaying]; + if (!initialSecondarySubtitleStream || !playbackManager.trackHasSecondarySubtitleSupport(initialSecondarySubtitleStream, this)) { + this.#secondarySubtitleTrackIndexToSetOnPlaying = -1; + } + } + } else { + this.#secondarySubtitleTrackIndexToSetOnPlaying = -1; + } + const crossOrigin = getCrossOriginValue(options.mediaSource); if (crossOrigin) { elem.crossOrigin = crossOrigin; @@ -490,8 +533,13 @@ function tryRemoveElement(elem) { this.setCurrentTrackElement(index); } + setSecondarySubtitleStreamIndex(index) { + this.setCurrentTrackElement(index, SECONDARY_TEXT_TRACK_INDEX); + } + resetSubtitleOffset() { this.#currentTrackOffset = 0; + this.#secondaryTrackOffset = 0; this.#showTrackOffset = false; } @@ -510,11 +558,11 @@ function tryRemoveElement(elem) { /** * @private */ - getTextTrack() { + getTextTracks() { 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'; }); @@ -523,9 +571,6 @@ function tryRemoveElement(elem) { } } - /** - * @private - */ setSubtitleOffset(offset) { const offsetValue = parseFloat(offset); @@ -534,12 +579,15 @@ function tryRemoveElement(elem) { this.updateCurrentTrackOffset(offsetValue); this.#currentSubtitlesOctopus.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue; } else { - const trackElement = this.getTextTrack(); + const trackElements = this.getTextTracks(); // if .vtt currently rendering - if (trackElement) { - this.setTextTrackSubtitleOffset(trackElement, offsetValue); - } else if (this.#currentTrackEvents) { - this.setTrackEventsSubtitleOffset(this.#currentTrackEvents, offsetValue); + if (trackElements?.length > 0) { + trackElements.forEach((trackElement, index) => { + this.setTextTrackSubtitleOffset(trackElement, offsetValue, index); + }); + } else if (this.#currentTrackEvents || this.#currentSecondaryTrackEvents) { + this.#currentTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentTrackEvents, offsetValue, PRIMARY_TEXT_TRACK_INDEX); + this.#currentSecondaryTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentSecondaryTrackEvents, offsetValue, SECONDARY_TEXT_TRACK_INDEX); } else { console.debug('No available track, cannot apply offset: ', offsetValue); } @@ -549,13 +597,25 @@ function tryRemoveElement(elem) { /** * @private */ - updateCurrentTrackOffset(offsetValue) { + updateCurrentTrackOffset(offsetValue, currentTrackIndex = PRIMARY_TEXT_TRACK_INDEX) { + let offsetToCompare = this.#currentTrackOffset; + if (this.isSecondaryTrack(currentTrackIndex)) { + offsetToCompare = this.#secondaryTrackOffset; + } + let relativeOffset = offsetValue; const newTrackOffset = offsetValue; - if (this.#currentTrackOffset) { - relativeOffset -= this.#currentTrackOffset; + + if (offsetToCompare) { + relativeOffset -= offsetToCompare; } - this.#currentTrackOffset = newTrackOffset; + + if (this.isSecondaryTrack(currentTrackIndex)) { + this.#secondaryTrackOffset = newTrackOffset; + } else { + this.#currentTrackOffset = newTrackOffset; + } + // relative to currentTrackOffset return relativeOffset; } @@ -563,9 +623,12 @@ function tryRemoveElement(elem) { /** * @private */ - setTextTrackSubtitleOffset(currentTrack, offsetValue) { + setTextTrackSubtitleOffset(currentTrack, offsetValue, currentTrackIndex) { if (currentTrack.cues) { - offsetValue = this.updateCurrentTrackOffset(offsetValue); + offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex); + if (offsetValue === 0) { + return; + } Array.from(currentTrack.cues) .forEach(function (cue) { cue.startTime -= offsetValue; @@ -577,9 +640,12 @@ function tryRemoveElement(elem) { /** * @private */ - setTrackEventsSubtitleOffset(trackEvents, offsetValue) { + setTrackEventsSubtitleOffset(trackEvents, offsetValue, currentTrackIndex) { if (Array.isArray(trackEvents)) { - offsetValue = this.updateCurrentTrackOffset(offsetValue) * 1e7; // ticks + offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex) * 1e7; // ticks + if (offsetValue === 0) { + return; + } trackEvents.forEach(function (trackEvent) { trackEvent.StartPositionTicks -= offsetValue; trackEvent.EndPositionTicks -= offsetValue; @@ -591,6 +657,14 @@ function tryRemoveElement(elem) { return this.#currentTrackOffset; } + isPrimaryTrack(textTrackIndex) { + return textTrackIndex === PRIMARY_TEXT_TRACK_INDEX; + } + + isSecondaryTrack(textTrackIndex) { + return textTrackIndex === SECONDARY_TEXT_TRACK_INDEX; + } + /** * @private */ @@ -819,6 +893,16 @@ function tryRemoveElement(elem) { if (this.#audioTrackIndexToSetOnPlaying != null && this.canSetAudioStreamIndex()) { this.setAudioStreamIndex(this.#audioTrackIndexToSetOnPlaying); } + + 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. + */ + setTimeout(() => this.setSecondarySubtitleStreamIndex(this.#secondarySubtitleTrackIndexToSetOnPlaying), 0); + } } /** @@ -956,27 +1040,75 @@ function tryRemoveElement(elem) { /** * @private */ - destroyCustomTrack(videoElement) { - if (this.#videoSubtitlesElem) { - const subtitlesContainer = this.#videoSubtitlesElem.parentNode; - if (subtitlesContainer) { - tryRemoveElement(subtitlesContainer); + destroyCustomRenderedTrackElements(targetTrackIndex) { + if (this.isPrimaryTrack(targetTrackIndex)) { + if (this.#videoSubtitlesElem) { + tryRemoveElement(this.#videoSubtitlesElem); + this.#videoSubtitlesElem = null; + } + } else if (this.isSecondaryTrack(targetTrackIndex)) { + if (this.#videoSecondarySubtitlesElem) { + tryRemoveElement(this.#videoSecondarySubtitlesElem); + this.#videoSecondarySubtitlesElem = null; + } + } else { // destroy all + if (this.#videoSubtitlesElem) { + const subtitlesContainer = this.#videoSubtitlesElem.parentNode; + if (subtitlesContainer) { + tryRemoveElement(subtitlesContainer); + } + this.#videoSubtitlesElem = null; + this.#videoSecondarySubtitlesElem = null; } - this.#videoSubtitlesElem = null; } + } - this.#currentTrackEvents = null; - + /** + * @private + */ + destroyNativeTracks(videoElement, targetTrackIndex) { if (videoElement) { + const destroySingleTrack = typeof targetTrackIndex === 'number'; 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]; + // Skip all other tracks if we are targeting just one + if (destroySingleTrack && targetTrackIndex !== index) { + continue; + } if (track.label.includes('manualTrack')) { track.mode = 'disabled'; } } } + } + + /** + * @private + */ + destroyStoredTrackInfo(targetTrackIndex) { + if (this.isPrimaryTrack(targetTrackIndex)) { + this.#customTrackIndex = -1; + this.#currentTrackEvents = null; + } else if (this.isSecondaryTrack(targetTrackIndex)) { + this.#customSecondaryTrackIndex = -1; + this.#currentSecondaryTrackEvents = null; + } else { // destroy all + this.#customTrackIndex = -1; + this.#customSecondaryTrackIndex = -1; + this.#currentTrackEvents = null; + this.#currentSecondaryTrackEvents = null; + } + } + + /** + * @private + */ + destroyCustomTrack(videoElement, targetTrackIndex) { + this.destroyCustomRenderedTrackElements(targetTrackIndex); + this.destroyNativeTracks(videoElement, targetTrackIndex); + this.destroyStoredTrackInfo(targetTrackIndex); - this.#customTrackIndex = -1; this.#currentClock = null; this._currentAspectRatio = null; @@ -1029,23 +1161,34 @@ function tryRemoveElement(elem) { /** * @private */ - setTrackForDisplay(videoElement, track) { + setTrackForDisplay(videoElement, track, targetTextTrackIndex = 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); } /** @@ -1155,16 +1298,39 @@ function tryRemoveElement(elem) { /** * @private */ - renderSubtitlesWithCustomElement(videoElement, track, item) { - this.fetchSubtitles(track, item).then((data) => { - if (!this.#videoSubtitlesElem) { - const subtitlesContainer = document.createElement('div'); - subtitlesContainer.classList.add('videoSubtitles'); - subtitlesContainer.innerHTML = '
'; - this.#videoSubtitlesElem = subtitlesContainer.querySelector('.videoSubtitlesInner'); + renderSubtitlesWithCustomElement(videoElement, track, item, targetTextTrackIndex) { + Promise.all([import('../../scripts/settings/userSettings'), this.fetchSubtitles(track, item)]).then((results) => { + const [userSettings, subtitleData] = results; + const subtitleAppearance = userSettings.getSubtitleAppearanceSettings(); + const subtitleVerticalPosition = parseInt(subtitleAppearance.verticalPosition, 10); + + if (!this.#videoSubtitlesElem && !this.isSecondaryTrack(targetTextTrackIndex)) { + let subtitlesContainer = document.querySelector('.videoSubtitles'); + if (!subtitlesContainer) { + subtitlesContainer = document.createElement('div'); + subtitlesContainer.classList.add('videoSubtitles'); + } + const subtitlesElement = document.createElement('div'); + subtitlesElement.classList.add('videoSubtitlesInner'); + subtitlesContainer.appendChild(subtitlesElement); + this.#videoSubtitlesElem = subtitlesElement; this.setSubtitleAppearance(subtitlesContainer, this.#videoSubtitlesElem); videoElement.parentNode.appendChild(subtitlesContainer); - this.#currentTrackEvents = data.TrackEvents; + this.#currentTrackEvents = subtitleData.TrackEvents; + } else if (!this.#videoSecondarySubtitlesElem && this.isSecondaryTrack(targetTextTrackIndex)) { + const subtitlesContainer = document.querySelector('.videoSubtitles'); + if (!subtitlesContainer) return; + const secondarySubtitlesElement = document.createElement('div'); + secondarySubtitlesElement.classList.add('videoSecondarySubtitlesInner'); + // determine the order of the subtitles + if (subtitleVerticalPosition < 0) { + subtitlesContainer.insertBefore(secondarySubtitlesElement, subtitlesContainer.firstChild); + } else { + subtitlesContainer.appendChild(secondarySubtitlesElement); + } + this.#videoSecondarySubtitlesElem = secondarySubtitlesElement; + this.setSubtitleAppearance(subtitlesContainer, this.#videoSecondarySubtitlesElem); + this.#currentSecondaryTrackEvents = subtitleData.TrackEvents; } }); } @@ -1211,7 +1377,7 @@ function tryRemoveElement(elem) { /** * @private */ - renderTracksEvents(videoElement, track, item) { + 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') { @@ -1220,15 +1386,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 { @@ -1288,24 +1454,29 @@ function tryRemoveElement(elem) { return; } - const trackEvents = this.#currentTrackEvents; - const subtitleTextElement = this.#videoSubtitlesElem; + const allTrackEvents = [this.#currentTrackEvents, this.#currentSecondaryTrackEvents]; + const subtitleTextElements = [this.#videoSubtitlesElem, this.#videoSecondarySubtitlesElem]; - if (trackEvents && subtitleTextElement) { - const ticks = timeMs * 10000; - let selectedTrackEvent; - for (const trackEvent of trackEvents) { - if (trackEvent.StartPositionTicks <= ticks && trackEvent.EndPositionTicks >= ticks) { - selectedTrackEvent = trackEvent; - break; + for (let i = 0; i < allTrackEvents.length; i++) { + const trackEvents = allTrackEvents[i]; + const subtitleTextElement = subtitleTextElements[i]; + + if (trackEvents && subtitleTextElement) { + const ticks = timeMs * 10000; + let selectedTrackEvent; + for (const trackEvent of trackEvents) { + if (trackEvent.StartPositionTicks <= ticks && trackEvent.EndPositionTicks >= ticks) { + selectedTrackEvent = trackEvent; + break; + } } - } - if (selectedTrackEvent && selectedTrackEvent.Text) { - subtitleTextElement.innerHTML = normalizeTrackEventText(selectedTrackEvent.Text, true); - subtitleTextElement.classList.remove('hide'); - } else { - subtitleTextElement.classList.add('hide'); + if (selectedTrackEvent && selectedTrackEvent.Text) { + subtitleTextElement.innerHTML = normalizeTrackEventText(selectedTrackEvent.Text, true); + subtitleTextElement.classList.remove('hide'); + } else { + subtitleTextElement.classList.add('hide'); + } } } } @@ -1313,7 +1484,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 +1493,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 +1671,7 @@ function tryRemoveElement(elem) { list.push('SetBrightness'); list.push('SetAspectRatio'); + list.push('SecondarySubtitles'); return list; } diff --git a/src/plugins/htmlVideoPlayer/style.scss b/src/plugins/htmlVideoPlayer/style.scss index 390e25a99..002614608 100644 --- a/src/plugins/htmlVideoPlayer/style.scss +++ b/src/plugins/htmlVideoPlayer/style.scss @@ -65,13 +65,22 @@ video[controls]::-webkit-media-controls { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); + display: flex; + flex-direction: column; + align-items: center; } .videoSubtitlesInner { max-width: 70%; background-color: rgba(0, 0, 0, 0.8); - margin: auto; - display: inline-block; +} + +.videoSecondarySubtitlesInner { + max-width: 70%; + background-color: rgba(0, 0, 0, 0.8); + min-height: 0 !important; + margin-top: 0.5em !important; + margin-bottom: 0.5em !important; } @keyframes htmlvideoplayer-zoomin { 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",