diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1fe5f517ab..0dd6a53512 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -21,6 +21,7 @@ - [RazeLighter777](https://github.com/RazeLighter777) - [LogicalPhallacy](https://github.com/LogicalPhallacy) - [thornbill](https://github.com/thornbill) + - [redSpoutnik](https://github.com/redSpoutnik) # Emby Contributors diff --git a/src/components/htmlvideoplayer/plugin.js b/src/components/htmlvideoplayer/plugin.js index d25b49163b..7d5fcaeda6 100644 --- a/src/components/htmlvideoplayer/plugin.js +++ b/src/components/htmlvideoplayer/plugin.js @@ -188,6 +188,9 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa var currentAssRenderer; var customTrackIndex = -1; + var showTrackOffset = false; + var currentTrackOffset; + var videoSubtitlesElem; var currentTrackEvents; @@ -543,6 +546,81 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa setCurrentTrackElement(index); }; + self.enableShowingSubtitleOffset = function() { + showTrackOffset = true; + } + + self.disableShowingSubtitleOffset = function() { + showTrackOffset = false; + } + + self.isShowingSubtitleOffsetEnabled = function() { + return showTrackOffset; + } + + self.setSubtitleOffset = function(offset) { + + var offsetValue = parseFloat(offset); + var videoElement = self._mediaElement; + var mediaStreamTextTracks = getMediaStreamTextTracks(self._currentPlayOptions.mediaSource); + + Array.from(videoElement.textTracks) + .filter(function(trackElement) { + if (customTrackIndex === -1 ) { + // get showing .vtt textTacks + return trackElement.mode === 'showing'; + } else { + // get current .ass textTrack + return ("textTrack" + customTrackIndex) === trackElement.id; + } + }) + .forEach(function(trackElement) { + + var track = mediaStreamTextTracks.filter(function(stream) { + return ("textTrack" + stream.Index) === trackElement.id; + })[0]; + + if(track) { + offsetValue = updateCurrentTrackOffset(offsetValue); + var format = (track.Codec || '').toLowerCase(); + if (format !== 'ass' && format !== 'ssa') { + setVttSubtitleOffset(trackElement, offsetValue); + } + } else { + console.log("No available track, cannot apply offset : " + offsetValue); + } + + }); + }; + + function updateCurrentTrackOffset(offsetValue) { + + var relativeOffset = offsetValue; + var newTrackOffset = offsetValue; + if(currentTrackOffset){ + relativeOffset -= currentTrackOffset; + } + currentTrackOffset = newTrackOffset; + // relative to currentTrackOffset + return relativeOffset; + } + + function setVttSubtitleOffset(currentTrack, offsetValue) { + + if(currentTrack.cues) { + Array.from(currentTrack.cues) + .forEach(function(cue) { + cue.startTime -= offsetValue; + cue.endTime -= offsetValue; + }); + } + + } + + self.getSubtitleOffset = function() { + return currentTrackOffset; + } + function isAudioStreamSupported(stream, deviceProfile) { var codec = (stream.Codec || '').toLowerCase(); @@ -1167,6 +1245,11 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa function updateSubtitleText(timeMs) { + // handle offset for ass tracks + if(currentTrackOffset) { + timeMs += (currentTrackOffset * 1000); + } + var clock = currentClock; if (clock) { try { diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index fedf7924ba..d9ea0a905b 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1635,6 +1635,46 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla getPlayerData(player).subtitleStreamIndex = index; }; + self.supportSubtitleOffset = function(player) { + player = player || self._currentPlayer; + return player && 'setSubtitleOffset' in player; + } + + self.enableShowingSubtitleOffset = function(player) { + player = player || self._currentPlayer; + player.enableShowingSubtitleOffset(); + } + + self.disableShowingSubtitleOffset = function(player) { + player = player || self._currentPlayer; + player.disableShowingSubtitleOffset(); + } + + self.isShowingSubtitleOffsetEnabled = function(player) { + player = player || self._currentPlayer; + return player.isShowingSubtitleOffsetEnabled(); + } + + self.isSubtitleStreamExternal = function(index, player) { + var stream = getSubtitleStream(player, index); + return stream ? getDeliveryMethod(stream) === 'External' : false; + } + + self.setSubtitleOffset = function (value, player) { + player = player || self._currentPlayer; + player.setSubtitleOffset(value); + }; + + self.getPlayerSubtitleOffset = function(player) { + player = player || self._currentPlayer; + return player.getSubtitleOffset(); + } + + self.canHandleOffsetOnCurrentSubtitle = function(player) { + var index = self.getSubtitleStreamIndex(player); + return index !== -1 && self.isSubtitleStreamExternal(index, player); + } + self.seek = function (ticks, player) { ticks = Math.max(0, ticks); diff --git a/src/components/playback/playersettingsmenu.js b/src/components/playback/playersettingsmenu.js index acfba81812..9d5b1f08ba 100644 --- a/src/components/playback/playersettingsmenu.js +++ b/src/components/playback/playersettingsmenu.js @@ -213,6 +213,15 @@ define(['connectionManager', 'actionsheet', 'datetime', 'playbackManager', 'glob }); } + if (options.suboffset) { + + menuItems.push({ + name: globalize.translate('SubtitleOffset'), + id: 'suboffset', + asideText: null + }); + } + return actionsheet.show({ items: menuItems, positionTo: options.positionTo @@ -248,6 +257,11 @@ define(['connectionManager', 'actionsheet', 'datetime', 'playbackManager', 'glob options.onOption('stats'); } return Promise.resolve(); + case 'suboffset': + if (options.onOption) { + options.onOption('suboffset'); + } + return Promise.resolve(); default: break; } diff --git a/src/components/subtitlesync/subtitlesync.css b/src/components/subtitlesync/subtitlesync.css new file mode 100644 index 0000000000..112e624722 --- /dev/null +++ b/src/components/subtitlesync/subtitlesync.css @@ -0,0 +1,48 @@ +.subtitleSyncContainer { + width: 40%; + margin-left: 30%; + margin-right: 30%; + height: 4.2em; + background: rgba(28,28,28,0.8); + border-radius: .3em; + color: #fff; + position: absolute; +} + +.subtitleSync-closeButton { + position: absolute; + top: 0; + right: 0; + color: #ccc; + z-index: 2; +} + +.subtitleSyncTextField { + position: absolute; + left: 0; + width: 40%; + margin-left: 30%; + margin-right: 30%; + top: 0.1em; + text-align: center; + font-size: 20px; + color: white; + z-index: 2; +} + +#prompt { + flex-shrink: 0; +} + +.subtitleSyncSliderContainer { + width: 98%; + margin-left: 1%; + margin-right: 1%; + top: 2.5em; + height: 1.4em; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + flex-grow: 1; + border-radius: .3em; + z-index: 1; +} \ No newline at end of file diff --git a/src/components/subtitlesync/subtitlesync.js b/src/components/subtitlesync/subtitlesync.js new file mode 100644 index 0000000000..3389b7bf89 --- /dev/null +++ b/src/components/subtitlesync/subtitlesync.js @@ -0,0 +1,163 @@ +define(['playbackManager', 'text!./subtitlesync.template.html', 'css!./subtitlesync'], function (playbackManager, template, css) { + "use strict"; + + var player; + var subtitleSyncSlider; + var subtitleSyncTextField; + var subtitleSyncCloseButton; + var subtitleSyncContainer; + + function init(instance) { + + var parent = document.createElement('div'); + parent.innerHTML = template; + + subtitleSyncSlider = parent.querySelector(".subtitleSyncSlider"); + subtitleSyncTextField = parent.querySelector(".subtitleSyncTextField"); + subtitleSyncCloseButton = parent.querySelector(".subtitleSync-closeButton"); + subtitleSyncContainer = parent.querySelector(".subtitleSyncContainer"); + + subtitleSyncContainer.classList.add("hide"); + + subtitleSyncTextField.updateOffset = function(offset) { + this.textContent = offset + "s"; + } + + subtitleSyncTextField.addEventListener("keypress", function(event) { + + if(event.key === "Enter"){ + // if input key is enter search for float pattern + var inputOffset = /[-+]?\d+\.?\d*/g.exec(this.textContent); + if(inputOffset) { + inputOffset = inputOffset[0]; + + // replace current text by considered offset + this.textContent = inputOffset + "s"; + + inputOffset = parseFloat(inputOffset); + // set new offset + playbackManager.setSubtitleOffset(inputOffset, player); + // synchronize with slider value + subtitleSyncSlider.updateOffset( + getPercentageFromOffset(inputOffset)); + } else { + this.textContent = (playbackManager.getPlayerSubtitleOffset(player) || 0) + "s"; + } + this.hasFocus = false; + event.preventDefault(); + } else { + // keep focus to prevent fade with bottom layout + this.hasFocus = true; + if(event.key.match(/[+-\d.s]/) === null) { + event.preventDefault(); + } + } + }); + + subtitleSyncSlider.updateOffset = function(percent) { + // default value is 0s = 50% + this.value = percent === undefined ? 50 : percent; + } + + subtitleSyncSlider.addEventListener("change", function () { + // set new offset + playbackManager.setSubtitleOffset(getOffsetFromPercentage(this.value), player); + // synchronize with textField value + subtitleSyncTextField.updateOffset( + getOffsetFromPercentage(this.value)); + }); + + subtitleSyncSlider.addEventListener("touchmove", function () { + // set new offset + playbackManager.setSubtitleOffset(getOffsetFromPercentage(this.value), player); + // synchronize with textField value + subtitleSyncTextField.updateOffset( + getOffsetFromPercentage(this.value)); + }); + + subtitleSyncSlider.getBubbleHtml = function (value) { + var newOffset = getOffsetFromPercentage(value); + return '

' + + (newOffset > 0 ? "+" : "") + parseFloat(newOffset) + "s" + + "

"; + }; + + subtitleSyncCloseButton.addEventListener("click", function() { + playbackManager.disableShowingSubtitleOffset(player); + SubtitleSync.prototype.toggle("forceToHide"); + }); + + document.body.appendChild(parent); + + instance.element = parent; + } + + + function getOffsetFromPercentage(value) { + // convert percent to fraction + var offset = (value - 50) / 50; + // multiply by offset min/max range value (-x to +x) : + offset *= 30; + return offset.toFixed(1); + }; + + function getPercentageFromOffset(value) { + // divide by offset min/max range value (-x to +x) : + var percentValue = value / 30; + // convert fraction to percent + percentValue *= 50; + percentValue += 50; + return Math.min(100, Math.max(0, percentValue.toFixed())); + }; + + function SubtitleSync(currentPlayer) { + player = currentPlayer; + init(this); + } + + SubtitleSync.prototype.destroy = function(){ + SubtitleSync.prototype.toggle("forceToHide"); + if(player){ + playbackManager.disableShowingSubtitleOffset(player); + playbackManager.setSubtitleOffset(0, player); + } + var elem = this.element; + if (elem) { + elem.parentNode.removeChild(elem); + this.element = null; + } + } + + SubtitleSync.prototype.toggle = function(action) { + + if(player && playbackManager.supportSubtitleOffset(player)){ + + switch(action) { + case undefined: + // if showing subtitle sync is enabled + if(playbackManager.isShowingSubtitleOffsetEnabled(player) && + // if there is an external subtitle stream enabled + playbackManager.canHandleOffsetOnCurrentSubtitle(player)){ + // if no subtitle offset is defined + if(!playbackManager.getPlayerSubtitleOffset(player)) { + // set default offset to '0' = 50% + subtitleSyncSlider.value = "50"; + subtitleSyncTextField.textContent = "0s"; + playbackManager.setSubtitleOffset(0, player); + } + // show subtitle sync + subtitleSyncContainer.classList.remove("hide"); + break; // stop here + } // else continue and hide + case "hide": + if(subtitleSyncTextField.hasFocus){break;} // else continue and hide + case "forceToHide": + subtitleSyncContainer.classList.add("hide"); + break; + } + + } + } + + return SubtitleSync; +}); diff --git a/src/components/subtitlesync/subtitlesync.template.html b/src/components/subtitlesync/subtitlesync.template.html new file mode 100644 index 0000000000..4ee344333d --- /dev/null +++ b/src/components/subtitlesync/subtitlesync.template.html @@ -0,0 +1,7 @@ +
+ +
0s
+
+ +
+
\ No newline at end of file diff --git a/src/controllers/videoosd.js b/src/controllers/videoosd.js index fe38386fe9..cfa7598ab7 100644 --- a/src/controllers/videoosd.js +++ b/src/controllers/videoosd.js @@ -295,8 +295,10 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med if (playbackManager.subtitleTracks(player).length) { view.querySelector(".btnSubtitles").classList.remove("hide"); + toggleSubtitleSync(); } else { view.querySelector(".btnSubtitles").classList.add("hide"); + toggleSubtitleSync("forceToHide"); } if (playbackManager.audioTracks(player).length > 1) { @@ -419,6 +421,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med focusManager.focus(elem.querySelector(".btnPause")); }, 50); } + toggleSubtitleSync(); } } @@ -431,6 +434,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med once: true }); currentVisibleMenu = null; + toggleSubtitleSync("hide"); } } @@ -622,6 +626,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med function releaseCurrentPlayer() { destroyStats(); + destroySubtitleSync(); resetUpNextDialog(); var player = currentPlayer; @@ -897,11 +902,17 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med var player = currentPlayer; if (player) { + + // show subtitle offset feature only if player and media support it + var showSubOffset = playbackManager.supportSubtitleOffset(player) && + playbackManager.canHandleOffsetOnCurrentSubtitle(player); + playerSettingsMenu.show({ mediaType: "Video", player: player, positionTo: btn, stats: true, + suboffset: showSubOffset, onOption: onSettingsOption }); } @@ -911,6 +922,12 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med function onSettingsOption(selectedOption) { if ("stats" === selectedOption) { toggleStats(); + } else if ("suboffset" === selectedOption) { + var player = currentPlayer; + if (player) { + playbackManager.enableShowingSubtitleOffset(player); + toggleSubtitleSync(); + } } } @@ -1008,10 +1025,30 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med if (index !== currentIndex) { playbackManager.setSubtitleStreamIndex(index, player); } + + toggleSubtitleSync(); }); }); } + function toggleSubtitleSync(action) { + require(["subtitleSync"], function (SubtitleSync) { + var player = currentPlayer; + if (subtitleSyncOverlay) { + subtitleSyncOverlay.toggle(action); + } else if(player){ + subtitleSyncOverlay = new SubtitleSync(player); + } + }); + } + + function destroySubtitleSync() { + if (subtitleSyncOverlay) { + subtitleSyncOverlay.destroy(); + subtitleSyncOverlay = null; + } + } + function onWindowKeyDown(e) { if (!currentVisibleMenu && (32 === e.keyCode || 13 === e.keyCode)) { playbackManager.playPause(currentPlayer); @@ -1146,6 +1183,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med var programStartDateMs = 0; var programEndDateMs = 0; var playbackStartTimeTicks = 0; + var subtitleSyncOverlay; var nowPlayingVolumeSlider = view.querySelector(".osdVolumeSlider"); var nowPlayingVolumeSliderContainer = view.querySelector(".osdVolumeSliderContainer"); var nowPlayingPositionSlider = view.querySelector(".osdPositionSlider"); @@ -1215,6 +1253,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med } destroyStats(); + destroySubtitleSync(); }); var lastPointerDown = 0; dom.addEventListener(view, window.PointerEvent ? "pointerdown" : "click", function (e) { @@ -1269,6 +1308,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med nowPlayingVolumeSlider.addEventListener("touchmove", function () { playbackManager.setVolume(this.value, currentPlayer); }); + nowPlayingPositionSlider.addEventListener("change", function () { var player = currentPlayer; diff --git a/src/scripts/site.js b/src/scripts/site.js index d60666b3a5..dbabeb607f 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -758,6 +758,7 @@ var AppInfo = {}; define("recordingButton", [componentsPath + "/recordingcreator/recordingbutton"], returnFirstDependency); define("recordingHelper", [componentsPath + "/recordingcreator/recordinghelper"], returnFirstDependency); define("subtitleEditor", [componentsPath + "/subtitleeditor/subtitleeditor"], returnFirstDependency); + define("subtitleSync", [componentsPath + "/subtitlesync/subtitlesync"], returnFirstDependency); define("itemIdentifier", [componentsPath + "/itemidentifier/itemidentifier"], returnFirstDependency); define("mediaInfo", [componentsPath + "/mediainfo/mediainfo"], returnFirstDependency); define("itemContextMenu", [componentsPath + "/itemcontextmenu"], returnFirstDependency); diff --git a/src/videoosd.html b/src/videoosd.html index e70adbe39c..72b93aa83b 100644 --- a/src/videoosd.html +++ b/src/videoosd.html @@ -1,4 +1,4 @@ -
+
@@ -59,6 +59,7 @@ +