diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index b3c519111e4..f5a8214601f 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -29,6 +29,7 @@ import { MediaError } from 'types/mediaError'; import { getMediaError } from 'utils/mediaError'; import { toApi } from 'utils/jellyfin-apiclient/compat'; import { bindSkipSegment } from './skipsegment.ts'; +import { TICKS_PER_SECOND } from 'constants/time'; const UNLIMITED_ITEMS = -1; @@ -1640,6 +1641,50 @@ export class PlaybackManager { return index !== -1 && self.isSubtitleStreamExternal(index, player); }; + /** + * Gets the current subtitle track events in a normalized format. + * This method attempts to get subtitle events from either: + * 1. The player's getCurrentTrackEvents method (for Jellyfin events) + * 2. The player's getTextTracks method (for VTT cues) + * + * @param {Object} player - The player instance to get subtitle events from + * @returns {Array<{startTime: number, endTime: number, text: string}>|null} An array of normalized subtitle events or null if none found + */ + self.getCurrentSubtitleTrackEvents = function (player) { + player = player || self._currentPlayer; + + // Try to get events using the new method + if (player && player.getCurrentTrackEvents) { + const events = player.getCurrentTrackEvents(); + if (events && events.length) { + // Convert ticks to seconds format + return events.map(event => ({ + startTime: event.StartPositionTicks / TICKS_PER_SECOND, + endTime: event.EndPositionTicks / TICKS_PER_SECOND, + text: event.Text || '' + })); + } + } + + // Try to get text tracks using the player's getTextTracks method + if (player && player.getTextTracks) { + const textTracks = player.getTextTracks(); + if (textTracks && textTracks.length > 0) { + const activeTrack = textTracks[0]; // Get the first active track + if (activeTrack && activeTrack.cues && activeTrack.cues.length > 0) { + // Convert VTTCues to our format + return Array.from(activeTrack.cues).map(cue => ({ + startTime: cue.startTime, + endTime: cue.endTime, + text: cue.text || '' + })); + } + } + } + + return null; + }; + self.seek = function (ticks, player) { ticks = Math.max(0, ticks); diff --git a/src/components/subtitlesync/subtitlesync.js b/src/components/subtitlesync/subtitlesync.js index aae73957e56..32e504ca4a8 100644 --- a/src/components/subtitlesync/subtitlesync.js +++ b/src/components/subtitlesync/subtitlesync.js @@ -1,170 +1,412 @@ - import { playbackManager } from '../playback/playbackmanager'; import layoutManager from '../layoutManager'; import template from './subtitlesync.template.html'; import './subtitlesync.scss'; +import { PlaybackSubscriber } from '../../apps/stable/features/playback/utils/playbackSubscriber'; -let player; -let subtitleSyncSlider; -let subtitleSyncTextField; -let subtitleSyncCloseButton; -let subtitleSyncContainer; - -function init(instance) { - const parent = document.createElement('div'); - document.body.appendChild(parent); - parent.innerHTML = template; - - subtitleSyncSlider = parent.querySelector('.subtitleSyncSlider'); - subtitleSyncTextField = parent.querySelector('.subtitleSyncTextField'); - subtitleSyncCloseButton = parent.querySelector('.subtitleSync-closeButton'); - subtitleSyncContainer = parent.querySelector('.subtitleSyncContainer'); - - if (layoutManager.tv) { - subtitleSyncSlider.classList.add('focusable'); - // HACK: Delay to give time for registered element attach (Firefox) - setTimeout(function () { - subtitleSyncSlider.enableKeyboardDragging(); - }, 0); - } - - subtitleSyncContainer.classList.add('hide'); - - subtitleSyncTextField.updateOffset = function (offset) { - this.textContent = offset + 's'; - }; - - subtitleSyncTextField.addEventListener('click', function () { - // keep focus to prevent fade with osd - this.hasFocus = true; - }); - - subtitleSyncTextField.addEventListener('keydown', function (event) { - if (event.key === 'Enter') { - // if input key is enter search for float pattern - let inputOffset = /[-+]?\d+\.?\d*/g.exec(this.textContent); - if (inputOffset) { - inputOffset = inputOffset[0]; - inputOffset = parseFloat(inputOffset); - - subtitleSyncSlider.updateOffset(inputOffset); - } else { - this.textContent = (playbackManager.getPlayerSubtitleOffset(player) || 0) + 's'; - } - this.hasFocus = false; - event.preventDefault(); - } else { - // keep focus to prevent fade with osd - this.hasFocus = true; - if (event.key.match(/[+-\d.s]/) === null) { - event.preventDefault(); - } +// Constants +const TIMELINE_RESOLUTION_SECONDS = 10.0; +const DEFAULT_OFFSET = 0; +const TIME_MARKER_INTERVAL = 1; // 1-second intervals for precise timing +const PERCENT_MAX = 100.0; + +function formatTimeMarker(time) { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function createSliderBubbleHtml(_, value) { + return '

' + + (value > 0 ? '+' : '') + parseFloat(value) + 's' + + '

'; +} + +class SubtitleTimeline extends PlaybackSubscriber { + constructor(timelineRuler, eventsContainer, timelineWrapper, player) { + super(playbackManager); + this.timelineRuler = timelineRuler; + this.subtitleEventsContainer = eventsContainer; + this.subtitleTimelineWrapper = timelineWrapper; + this.player = player; + this.currentSubtitles = null; + } + + onPlayerTimeUpdate() { + this._updateTimelineVisualization(); + } + + _updateTimelineVisualization() { + if (!this.currentSubtitles || !this.currentSubtitles.length) { + this.hide(); + return; } - // FIXME: TV layout will require special handling for navigation keys. But now field is not focusable - event.stopPropagation(); - }); + // Get the current player time in seconds + const currentTime = this._getCurrentPlayerTime(); + + // Render the timeline with current subtitles + this.render(currentTime); + } + + _getCurrentPlayerTime() { + return (this.player ? (playbackManager.currentTime(this.player) || 0) : 0) / 1000.0; + } + + _getSubtitleEvents() { + if (!this.player) return null; + return playbackManager.getCurrentSubtitleTrackEvents(this.player); + } + + updateEvents() { + // Get subtitles in our unified seconds-based format + this.currentSubtitles = this._getSubtitleEvents(); + this._updateTimelineVisualization(); + } + + getTimeWindow(currentTime) { + const startTime = Math.max(0, currentTime - (TIMELINE_RESOLUTION_SECONDS / 2)); + const endTime = startTime + TIMELINE_RESOLUTION_SECONDS; + return { startTime, endTime }; + } - subtitleSyncTextField.blur = function () { - // prevent textfield to blur while element has focus - if (!this.hasFocus && this.prototype) { - this.prototype.blur(); + generateTimeMarkers(currentTime) { + // Clear existing markers + this.timelineRuler.innerHTML = ''; + + const timeWindow = this.getTimeWindow(currentTime); + this._createTimeMarkers(timeWindow.startTime, timeWindow.endTime); + } + + _createTimeMarkers(startTime, endTime) { + for (let time = startTime; time <= endTime; time += TIME_MARKER_INTERVAL) { + const marker = this._createTimeMarker(time, startTime); + this.timelineRuler.appendChild(marker); } - }; + } - function updateSubtitleOffset() { - const value = parseFloat(subtitleSyncSlider.value); - // set new offset - playbackManager.setSubtitleOffset(value, player); - // synchronize with textField value - subtitleSyncTextField.updateOffset(value); + _createTimeMarker(time, startTime) { + const marker = document.createElement('div'); + marker.classList.add('timelineMarker'); + + // Calculate position as percentage + const position = (((time - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX); + marker.style.left = `${position}%`; + + // Format time as MM:SS + marker.textContent = formatTimeMarker(time); + + return marker; } - subtitleSyncSlider.updateOffset = function (sliderValue) { - // default value is 0s = 0ms - this.value = sliderValue === undefined ? 0 : sliderValue; + renderEvents(currentTime) { + // Clear existing events + this.subtitleEventsContainer.innerHTML = ''; - updateSubtitleOffset(); - }; + if (!this.currentSubtitles || this.currentSubtitles.length === 0) { + // Hide the timeline wrapper when no events exist + this.subtitleTimelineWrapper.style.display = 'none'; + return; + } - subtitleSyncSlider.addEventListener('change', () => updateSubtitleOffset()); + // Show the timeline wrapper when events exist + this.subtitleTimelineWrapper.style.display = ''; - subtitleSyncSlider.getBubbleHtml = function (_, value) { - return '

' - + (value > 0 ? '+' : '') + parseFloat(value) + 's' - + '

'; - }; + const timeWindow = this.getTimeWindow(currentTime); + const { startTime, endTime } = timeWindow; - subtitleSyncCloseButton.addEventListener('click', function () { - playbackManager.disableShowingSubtitleOffset(player); - SubtitleSync.prototype.toggle('forceToHide'); - }); + this.currentSubtitles.forEach(subtitle => { + if (!this._isEventVisible(subtitle, startTime, endTime)) return; - instance.element = parent; -} + const eventElement = this._createEventElement(subtitle, startTime); + this.subtitleEventsContainer.appendChild(eventElement); + }); + } -class SubtitleSync { - constructor(currentPlayer) { - player = currentPlayer; - init(this); + _isEventVisible(subtitle, startTime, endTime) { + return !(subtitle.endTime <= startTime || subtitle.startTime >= endTime); + } + + _createEventElement(subtitle, startTime) { + // Calculate position and width as percentages exactly proportional to duration + // Clamp to visible area + const leftPos = Math.max(0, ((subtitle.startTime - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX); + const rightPos = Math.min(PERCENT_MAX, ((subtitle.endTime - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX); + + const eventEl = document.createElement('div'); + eventEl.classList.add('subtitleEvent'); + + // Apply position and width exactly as calculated + eventEl.style.left = `${leftPos}%`; + eventEl.style.width = `${rightPos - leftPos}%`; + + // Clean and add the text + eventEl.textContent = subtitle.text; + // Add a title for the full text on hover + eventEl.title = subtitle.text; + + return eventEl; + } + + render(currentTime) { + // Generate time markers - the time markers don't shift with offset + this.generateTimeMarkers(currentTime); + + // Render subtitle events with the current offset + this.renderEvents(currentTime); + } + + hide() { + this.subtitleTimelineWrapper.style.display = 'none'; } destroy() { - SubtitleSync.prototype.toggle('forceToHide'); - if (player) { - playbackManager.disableShowingSubtitleOffset(player); - playbackManager.setSubtitleOffset(0, player); - } - const elem = this.element; - if (elem) { - elem.parentNode.removeChild(elem); - this.element = null; - } + this.currentSubtitles = null; + + // Call parent class destroy to clean up event subscriptions + super.destroy?.(); } +} - toggle(action) { - if (action && !['hide', 'forceToHide'].includes(action)) { - console.warn('SubtitleSync.toggle called with invalid action', action); - return; +class OffsetController { + constructor(player, slider, textField, onOffsetChange) { + this.player = player; + this.slider = slider; + this.textField = textField; + this.currentOffset = DEFAULT_OFFSET; + this.onOffsetChange = onOffsetChange; + + this._initSlider(); + this._initTextField(); + } + + _initSlider() { + const slider = this.slider; + + if (layoutManager.tv) { + slider.classList.add('focusable'); + // eslint-disable-next-line no-warning-comments + // HACK: Delay to give time for registered element attach (Firefox) + setTimeout(() => slider.enableKeyboardDragging(), 0); } - if (player && playbackManager.supportSubtitleOffset(player)) { - if (!action) { - // if showing subtitle sync is enabled and if there is an external subtitle stream enabled - if (playbackManager.isShowingSubtitleOffsetEnabled(player) && playbackManager.canHandleOffsetOnCurrentSubtitle(player)) { - // if no subtitle offset is defined or element has focus (offset being defined) - if (!(playbackManager.getPlayerSubtitleOffset(player) || subtitleSyncTextField.hasFocus)) { - // set default offset to '0' = 0ms - subtitleSyncSlider.value = '0'; - subtitleSyncTextField.textContent = '0s'; - playbackManager.setSubtitleOffset(0, player); - } - // show subtitle sync - subtitleSyncContainer.classList.remove('hide'); - return; + slider.addEventListener('change', () => this.updateOffset()); + slider.getBubbleHtml = createSliderBubbleHtml; + + // Simplified slider update method + slider.updateOffset = (value) => { + this.slider.value = value === undefined ? DEFAULT_OFFSET : value; + this.updateOffset(); + }; + } + + _initTextField() { + const textField = this.textField; + + textField.updateOffset = (offset) => { + textField.textContent = offset + 's'; + }; + + textField.addEventListener('click', () => { + // keep focus to prevent fade with osd + textField.hasFocus = true; + }); + + textField.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + // if input key is enter search for float pattern + let inputOffset = /[-+]?\d+\.?\d*/g.exec(textField.textContent); + if (inputOffset) { + inputOffset = parseFloat(inputOffset[0]); + this.slider.updateOffset(inputOffset); + } else { + textField.textContent = this.currentOffset + 's'; + } + textField.hasFocus = false; + event.preventDefault(); + } else { + // keep focus to prevent fade with osd + textField.hasFocus = true; + if (event.key.match(/[+-\d.s]/) === null) { + event.preventDefault(); } - } else if (action === 'hide' && subtitleSyncTextField.hasFocus) { - // do not hide if element has focus - return; } + event.stopPropagation(); + }); + + textField.blur = function() { + // prevent textfield to blur while element has focus + if (!this.hasFocus && this.prototype) { + this.prototype.blur(); + } + }; + } + + updateOffset() { + const value = parseFloat(this.slider.value); - subtitleSyncContainer.classList.add('hide'); + // set new offset + playbackManager.setSubtitleOffset(value, this.player); + + // synchronize with textField value + this.textField.updateOffset(value); + + // update current offset + this.currentOffset = value; + + // notify listeners + if (this.onOffsetChange) { + this.onOffsetChange(value); } } - update(offset) { - this.toggle(); + adjustOffset(delta) { + const value = parseFloat(this.slider.value) + delta; + this.slider.updateOffset(value); + } + + setOffset(offset) { + this.currentOffset = offset; + this.slider.value = offset.toString(); + this.textField.updateOffset(offset); + } + + reset() { + this.setOffset(DEFAULT_OFFSET); + playbackManager.setSubtitleOffset(DEFAULT_OFFSET, this.player); + } +} + +class SubtitleSync { + constructor(currentPlayer) { + this.player = currentPlayer; + + this._initUI(); + + // Create the timeline controller + this.timeline = new SubtitleTimeline( + this.timelineRuler, + this.subtitleEventsContainer, + this.subtitleTimelineWrapper, + this.player + ); + + // Create the offset controller + this.offsetController = new OffsetController( + this.player, + this.subtitleSyncSlider, + this.subtitleSyncTextField, + () => this._handleOffsetChange() + ); + } + + _initUI() { + const parent = document.createElement('div'); + document.body.appendChild(parent); + parent.innerHTML = template; + + // Store DOM elements + this.element = parent; + this.subtitleSyncSlider = parent.querySelector('.subtitleSyncSlider'); + this.subtitleSyncTextField = parent.querySelector('.subtitleSyncTextField'); + this.subtitleSyncCloseButton = parent.querySelector('.subtitleSync-closeButton'); + this.subtitleSyncContainer = parent.querySelector('.subtitleSyncContainer'); + this.timelineRuler = parent.querySelector('.timelineRuler'); + this.subtitleEventsContainer = parent.querySelector('.subtitleEventsContainer'); + this.subtitleTimelineWrapper = parent.querySelector('.subtitleTimelineWrapper'); + + this._setupCloseButton(); + + // Initially hide the container + this.subtitleSyncContainer.classList.add('hide'); + } + + _setupCloseButton() { + this.subtitleSyncCloseButton.addEventListener('click', () => { + playbackManager.disableShowingSubtitleOffset(this.player); + this.toggle('forceToHide'); + }); + } - const value = parseFloat(subtitleSyncSlider.value) + offset; - subtitleSyncSlider.updateOffset(value); + _handleOffsetChange() { + // Wait a short time for the player to apply the offset to track events + setTimeout(() => { + // Update timeline with fresh events + this.timeline.updateEvents(); + }, 150); // Small delay to ensure offset has been applied to events + } + + _tryShowSubtitleSync() { + // if showing subtitle sync is enabled and if there is an external subtitle stream enabled + if (!this._canShowSubtitleSync()) { + this.subtitleSyncContainer.classList.add('hide'); + return; + } + + // Update current offset from player + const currentOffset = playbackManager.getPlayerSubtitleOffset(this.player) || DEFAULT_OFFSET; + this.offsetController.setOffset(currentOffset); + + // show subtitle sync + this.subtitleSyncContainer.classList.remove('hide'); + + // Initialize the timeline visualization with the current offset + this.timeline.updateEvents(); + } + + _canShowSubtitleSync() { + return playbackManager.isShowingSubtitleOffsetEnabled(this.player) + && playbackManager.canHandleOffsetOnCurrentSubtitle(this.player); } incrementOffset() { - this.update(+subtitleSyncSlider.step); + this.toggle(); + this.offsetController.adjustOffset(+this.subtitleSyncSlider.step); } decrementOffset() { - this.update(-subtitleSyncSlider.step); + this.toggle(); + this.offsetController.adjustOffset(-this.subtitleSyncSlider.step); + } + + destroy() { + if (this.timeline) { + this.timeline.destroy(); + this.timeline = null; + } + + this.toggle('forceToHide'); + if (this.player) { + playbackManager.disableShowingSubtitleOffset(this.player); + this.offsetController.reset(); + } + + if (this.element) { + this.element.parentNode.removeChild(this.element); + this.element = null; + } + + this.player = null; + } + + toggle(action) { + if (action && !['hide', 'forceToHide'].includes(action)) { + console.warn('SubtitleSync.toggle called with invalid action', action); + return; + } + + if (!this.player || !playbackManager.supportSubtitleOffset(this.player)) { + return; + } + + if (!action) { + this._tryShowSubtitleSync(); + } else if (action === 'hide' && this.subtitleSyncTextField.hasFocus) { + // do not hide if element has focus + return; + } else { + this.subtitleSyncContainer.classList.add('hide'); + } } } diff --git a/src/components/subtitlesync/subtitlesync.scss b/src/components/subtitlesync/subtitlesync.scss index b0c3d6a759f..6db976d0f50 100644 --- a/src/components/subtitlesync/subtitlesync.scss +++ b/src/components/subtitlesync/subtitlesync.scss @@ -4,36 +4,32 @@ } .subtitleSyncContainer { - width: 40%; - min-width: 18em; - margin-left: auto; - margin-right: auto; - height: 4.2em; - background: rgba(28, 28, 28, 0.8); - border-radius: 0.3em; - color: #fff; + width: 50%; + min-width: 24em; + margin: 0 auto; + background: rgba(32, 32, 32, 0.9); + border-radius: 0.2em; + color: rgba(255, 255, 255, 0.8); position: relative; + padding: 1rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; } .subtitleSync-closeButton { position: absolute; - top: 0; - right: 0; - color: #ccc; + top: 0.5rem; + right: 0.5rem; + color: rgba(255, 255, 255, 0.7); 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; + color: rgba(255, 255, 255, 0.8); } #prompt { @@ -42,11 +38,101 @@ .subtitleSyncSliderContainer { width: 98%; - margin-left: 1%; - margin-right: 1%; - top: 2.5em; - height: 1.4em; - flex-grow: 1; border-radius: 0.3em; + position: relative; +} + +.subtitleTimelineWrapper { + width: 100%; + position: relative; +} + +/* Timeline view styles */ +.subtitleSyncTimeline { + position: relative; + width: 100%; + height: 7em; + background: rgba(0, 0, 0, 0.2); + border-radius: 0.2em; + overflow: hidden; +} + +.timelineRuler { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1.8em; + background: rgba(0, 0, 0, 0.2); z-index: 1; + padding: 0 0.5em; +} + +.timelineMarker { + position: absolute; + font-size: 0.8em; + color: rgba(255, 255, 255, 0.7); + text-align: center; + width: 2.5em; + margin-left: -1.25em; +} + +.timelineMarker::after { + content: ''; + position: absolute; + bottom: -0.3em; + left: 50%; + width: 1px; + height: 0.3em; + background: rgba(255, 255, 255, 0.5); +} + +.subtitleEventsContainer { + position: absolute; + top: 2em; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + padding: 0.5em; +} + +.subtitleEvent { + position: absolute; + background: rgba(41, 41, 41, 0.8); + border: 1px solid #00a4dc; + border-radius: 0.2em; + padding: 0.3em 0.5em; + color: rgba(255, 255, 255, 0.8); + font-size: 0.8em; + height: 3.2em; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: none; + transition: background-color 0.2s; + top: 0.9em; + text-align: left; + white-space: normal; + line-height: 1.2; + box-sizing: border-box; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.subtitleEvent:hover { + transform: none; + z-index: 10; + background: rgba(51, 51, 51, 0.9); +} + +.currentTimeIndicator { + position: absolute; + top: 2em; + bottom: 0.5em; + width: 2px; + background: #00a4dc; + left: 50%; + z-index: 2; } diff --git a/src/components/subtitlesync/subtitlesync.template.html b/src/components/subtitlesync/subtitlesync.template.html index eb36d582601..0f0f6f185f6 100644 --- a/src/components/subtitlesync/subtitlesync.template.html +++ b/src/components/subtitlesync/subtitlesync.template.html @@ -5,5 +5,18 @@
+
+
+
+ +
+
+
+
+ +
+
+
+
diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js index fcaff8f3235..136d9079136 100644 --- a/src/plugins/htmlVideoPlayer/plugin.js +++ b/src/plugins/htmlVideoPlayer/plugin.js @@ -728,6 +728,10 @@ export class HtmlVideoPlayer { return this.#currentTrackOffset; } + getCurrentTrackEvents() { + return this.#currentTrackEvents || []; + } + isPrimaryTrack(textTrackIndex) { return textTrackIndex === PRIMARY_TEXT_TRACK_INDEX; }