1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #220 from redSpoutnik/synchronize-subtitles

Add subtitle synchronization on HTML video player
This commit is contained in:
Anthony Lavado 2019-04-30 15:18:55 -04:00 committed by GitHub
commit d7a0a9b5bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 399 additions and 1 deletions

View file

@ -21,6 +21,7 @@
- [RazeLighter777](https://github.com/RazeLighter777) - [RazeLighter777](https://github.com/RazeLighter777)
- [LogicalPhallacy](https://github.com/LogicalPhallacy) - [LogicalPhallacy](https://github.com/LogicalPhallacy)
- [thornbill](https://github.com/thornbill) - [thornbill](https://github.com/thornbill)
- [redSpoutnik](https://github.com/redSpoutnik)
# Emby Contributors # Emby Contributors

View file

@ -188,6 +188,9 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
var currentAssRenderer; var currentAssRenderer;
var customTrackIndex = -1; var customTrackIndex = -1;
var showTrackOffset = false;
var currentTrackOffset;
var videoSubtitlesElem; var videoSubtitlesElem;
var currentTrackEvents; var currentTrackEvents;
@ -543,6 +546,81 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
setCurrentTrackElement(index); 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) { function isAudioStreamSupported(stream, deviceProfile) {
var codec = (stream.Codec || '').toLowerCase(); var codec = (stream.Codec || '').toLowerCase();
@ -1167,6 +1245,11 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
function updateSubtitleText(timeMs) { function updateSubtitleText(timeMs) {
// handle offset for ass tracks
if(currentTrackOffset) {
timeMs += (currentTrackOffset * 1000);
}
var clock = currentClock; var clock = currentClock;
if (clock) { if (clock) {
try { try {

View file

@ -1635,6 +1635,46 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
getPlayerData(player).subtitleStreamIndex = index; 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) { self.seek = function (ticks, player) {
ticks = Math.max(0, ticks); ticks = Math.max(0, ticks);

View file

@ -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({ return actionsheet.show({
items: menuItems, items: menuItems,
positionTo: options.positionTo positionTo: options.positionTo
@ -248,6 +257,11 @@ define(['connectionManager', 'actionsheet', 'datetime', 'playbackManager', 'glob
options.onOption('stats'); options.onOption('stats');
} }
return Promise.resolve(); return Promise.resolve();
case 'suboffset':
if (options.onOption) {
options.onOption('suboffset');
}
return Promise.resolve();
default: default:
break; break;
} }

View file

@ -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;
}

View file

@ -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 '<h1 class="sliderBubbleText">' +
(newOffset > 0 ? "+" : "") + parseFloat(newOffset) + "s" +
"</h1>";
};
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;
});

View file

@ -0,0 +1,7 @@
<div class="subtitleSyncContainer">
<button type="button" is="paper-icon-button-light" class="subtitleSync-closeButton"><i class="md-icon">close</i></button>
<div class="subtitleSyncTextField" contenteditable="true" spellcheck="false">0s</div>
<div class="sliderContainer subtitleSyncSliderContainer">
<input is="emby-slider" type="range" step="1" min="0" max="100" value="50" class="subtitleSyncSlider" />
</div>
</div>

View file

@ -295,8 +295,10 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
if (playbackManager.subtitleTracks(player).length) { if (playbackManager.subtitleTracks(player).length) {
view.querySelector(".btnSubtitles").classList.remove("hide"); view.querySelector(".btnSubtitles").classList.remove("hide");
toggleSubtitleSync();
} else { } else {
view.querySelector(".btnSubtitles").classList.add("hide"); view.querySelector(".btnSubtitles").classList.add("hide");
toggleSubtitleSync("forceToHide");
} }
if (playbackManager.audioTracks(player).length > 1) { if (playbackManager.audioTracks(player).length > 1) {
@ -419,6 +421,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
focusManager.focus(elem.querySelector(".btnPause")); focusManager.focus(elem.querySelector(".btnPause"));
}, 50); }, 50);
} }
toggleSubtitleSync();
} }
} }
@ -431,6 +434,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
once: true once: true
}); });
currentVisibleMenu = null; currentVisibleMenu = null;
toggleSubtitleSync("hide");
} }
} }
@ -622,6 +626,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
function releaseCurrentPlayer() { function releaseCurrentPlayer() {
destroyStats(); destroyStats();
destroySubtitleSync();
resetUpNextDialog(); resetUpNextDialog();
var player = currentPlayer; var player = currentPlayer;
@ -897,11 +902,17 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
var player = currentPlayer; var player = currentPlayer;
if (player) { if (player) {
// show subtitle offset feature only if player and media support it
var showSubOffset = playbackManager.supportSubtitleOffset(player) &&
playbackManager.canHandleOffsetOnCurrentSubtitle(player);
playerSettingsMenu.show({ playerSettingsMenu.show({
mediaType: "Video", mediaType: "Video",
player: player, player: player,
positionTo: btn, positionTo: btn,
stats: true, stats: true,
suboffset: showSubOffset,
onOption: onSettingsOption onOption: onSettingsOption
}); });
} }
@ -911,6 +922,12 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
function onSettingsOption(selectedOption) { function onSettingsOption(selectedOption) {
if ("stats" === selectedOption) { if ("stats" === selectedOption) {
toggleStats(); 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) { if (index !== currentIndex) {
playbackManager.setSubtitleStreamIndex(index, player); 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) { function onWindowKeyDown(e) {
if (!currentVisibleMenu && (32 === e.keyCode || 13 === e.keyCode)) { if (!currentVisibleMenu && (32 === e.keyCode || 13 === e.keyCode)) {
playbackManager.playPause(currentPlayer); playbackManager.playPause(currentPlayer);
@ -1146,6 +1183,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
var programStartDateMs = 0; var programStartDateMs = 0;
var programEndDateMs = 0; var programEndDateMs = 0;
var playbackStartTimeTicks = 0; var playbackStartTimeTicks = 0;
var subtitleSyncOverlay;
var nowPlayingVolumeSlider = view.querySelector(".osdVolumeSlider"); var nowPlayingVolumeSlider = view.querySelector(".osdVolumeSlider");
var nowPlayingVolumeSliderContainer = view.querySelector(".osdVolumeSliderContainer"); var nowPlayingVolumeSliderContainer = view.querySelector(".osdVolumeSliderContainer");
var nowPlayingPositionSlider = view.querySelector(".osdPositionSlider"); var nowPlayingPositionSlider = view.querySelector(".osdPositionSlider");
@ -1215,6 +1253,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
} }
destroyStats(); destroyStats();
destroySubtitleSync();
}); });
var lastPointerDown = 0; var lastPointerDown = 0;
dom.addEventListener(view, window.PointerEvent ? "pointerdown" : "click", function (e) { dom.addEventListener(view, window.PointerEvent ? "pointerdown" : "click", function (e) {
@ -1269,6 +1308,7 @@ define(["playbackManager", "dom", "inputmanager", "datetime", "itemHelper", "med
nowPlayingVolumeSlider.addEventListener("touchmove", function () { nowPlayingVolumeSlider.addEventListener("touchmove", function () {
playbackManager.setVolume(this.value, currentPlayer); playbackManager.setVolume(this.value, currentPlayer);
}); });
nowPlayingPositionSlider.addEventListener("change", function () { nowPlayingPositionSlider.addEventListener("change", function () {
var player = currentPlayer; var player = currentPlayer;

View file

@ -758,6 +758,7 @@ var AppInfo = {};
define("recordingButton", [componentsPath + "/recordingcreator/recordingbutton"], returnFirstDependency); define("recordingButton", [componentsPath + "/recordingcreator/recordingbutton"], returnFirstDependency);
define("recordingHelper", [componentsPath + "/recordingcreator/recordinghelper"], returnFirstDependency); define("recordingHelper", [componentsPath + "/recordingcreator/recordinghelper"], returnFirstDependency);
define("subtitleEditor", [componentsPath + "/subtitleeditor/subtitleeditor"], returnFirstDependency); define("subtitleEditor", [componentsPath + "/subtitleeditor/subtitleeditor"], returnFirstDependency);
define("subtitleSync", [componentsPath + "/subtitlesync/subtitlesync"], returnFirstDependency);
define("itemIdentifier", [componentsPath + "/itemidentifier/itemidentifier"], returnFirstDependency); define("itemIdentifier", [componentsPath + "/itemidentifier/itemidentifier"], returnFirstDependency);
define("mediaInfo", [componentsPath + "/mediainfo/mediainfo"], returnFirstDependency); define("mediaInfo", [componentsPath + "/mediainfo/mediainfo"], returnFirstDependency);
define("itemContextMenu", [componentsPath + "/itemcontextmenu"], returnFirstDependency); define("itemContextMenu", [componentsPath + "/itemcontextmenu"], returnFirstDependency);

View file

@ -1,4 +1,4 @@
<div id="videoOsdPage" data-role="page" class="page libraryPage" data-backbutton="true"> <div id="videoOsdPage" data-role="page" class="page libraryPage" data-backbutton="true">
<div class="pageContainer flex"> <div class="pageContainer flex">
</div> </div>
@ -59,6 +59,7 @@
<button is="paper-icon-button-light" class="btnSubtitles hide autoSize" title="${Subtitles}"> <button is="paper-icon-button-light" class="btnSubtitles hide autoSize" title="${Subtitles}">
<i class="xlargePaperIconButton md-icon">&#xE01C;</i> <i class="xlargePaperIconButton md-icon">&#xE01C;</i>
</button> </button>
<button is="paper-icon-button-light" class="btnVideoOsdSettings hide autoSize" title="${Settings}"> <button is="paper-icon-button-light" class="btnVideoOsdSettings hide autoSize" title="${Settings}">
<i class="largePaperIconButton md-icon">&#xE8B8;</i> <i class="largePaperIconButton md-icon">&#xE8B8;</i>
</button> </button>