Skip to content

Commit 1096cbd

Browse files
author
Armanc
committed
Add subtitle sync timeline for easy alignment
Render a scrolling timeline of subtitle events around the current playback position below the offset slider, so users can visually align subtitles with the video. Dragging the offset shifts the subtitle blocks against a fixed time ruler, making the correct alignment easy to find. The timeline reuses pooled DOM nodes and updates their positions on each player time update, keeping it cheap enough to run during playback on mobile. It is only shown for text subtitle tracks whose events can be read back from the player.
1 parent 6761bac commit 1096cbd

3 files changed

Lines changed: 303 additions & 27 deletions

File tree

src/components/subtitlesync/subtitlesync.js

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,172 @@ import { playbackManager } from '../playback/playbackmanager';
22
import layoutManager from '../layoutManager';
33
import template from './subtitlesync.template.html';
44
import './subtitlesync.scss';
5+
import { PlaybackSubscriber } from '../../apps/stable/features/playback/utils/playbackSubscriber';
56

67
// Constants
78
const DEFAULT_OFFSET = 0;
9+
// Width of the visible timeline window, centered on the current playback time.
10+
const TIMELINE_RESOLUTION_SECONDS = 10;
11+
const TIME_MARKER_INTERVAL_SECONDS = 1;
12+
const PERCENT_MAX = 100;
13+
const MILLISECONDS_PER_SECOND = 1000;
14+
// Time for the player to apply a new offset to its track events before we re-read them.
15+
const OFFSET_APPLY_DELAY_MS = 150;
16+
17+
function formatTimeMarker(timeSeconds) {
18+
const minutes = Math.floor(timeSeconds / 60);
19+
const seconds = Math.floor(timeSeconds % 60);
20+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
21+
}
822

923
function createSliderBubbleHtml(_, value) {
1024
return '<h1 class="sliderBubbleText">'
1125
+ (value > 0 ? '+' : '') + Number.parseFloat(value) + 's'
1226
+ '</h1>';
1327
}
1428

29+
/**
30+
* Renders a scrolling timeline of subtitle events around the current playback
31+
* position so the user can visually align the offset.
32+
*
33+
* Subscribes to player time updates via PlaybackSubscriber. To stay cheap enough
34+
* for every timeupdate (including on mobile), DOM nodes are pooled and reused:
35+
* each render updates the position, text and visibility of existing nodes rather
36+
* than clearing the container and rebuilding it.
37+
*/
38+
class SubtitleTimeline extends PlaybackSubscriber {
39+
#timelineRuler;
40+
#eventsContainer;
41+
#timelineWrapper;
42+
#player;
43+
#currentSubtitles = null;
44+
#markerPool = [];
45+
#eventPool = [];
46+
47+
constructor(timelineRuler, eventsContainer, timelineWrapper, player) {
48+
super(playbackManager);
49+
this.#timelineRuler = timelineRuler;
50+
this.#eventsContainer = eventsContainer;
51+
this.#timelineWrapper = timelineWrapper;
52+
this.#player = player;
53+
}
54+
55+
onPlayerTimeUpdate() {
56+
this.#updateVisualization();
57+
}
58+
59+
/** Re-reads subtitle events from the player and refreshes the timeline. */
60+
updateEvents() {
61+
this.#currentSubtitles = this.#player ?
62+
playbackManager.getCurrentSubtitleTrackEvents(this.#player) :
63+
null;
64+
this.#updateVisualization();
65+
}
66+
67+
hide() {
68+
this.#timelineWrapper.style.display = 'none';
69+
}
70+
71+
destroy() {
72+
this.#currentSubtitles = null;
73+
// Clean up the player event subscriptions registered by PlaybackSubscriber.
74+
super.destroy?.();
75+
}
76+
77+
#updateVisualization() {
78+
if (!this.#currentSubtitles?.length) {
79+
this.hide();
80+
return;
81+
}
82+
83+
this.#timelineWrapper.style.display = '';
84+
85+
const currentTimeSeconds = this.#getCurrentPlayerTimeSeconds();
86+
const startTime = Math.max(0, currentTimeSeconds - (TIMELINE_RESOLUTION_SECONDS / 2));
87+
const endTime = startTime + TIMELINE_RESOLUTION_SECONDS;
88+
89+
this.#renderMarkers(startTime);
90+
this.#renderEvents(startTime, endTime);
91+
}
92+
93+
#getCurrentPlayerTimeSeconds() {
94+
if (!this.#player) {
95+
return 0;
96+
}
97+
return (playbackManager.currentTime(this.#player) || 0) / MILLISECONDS_PER_SECOND;
98+
}
99+
100+
#renderMarkers(startTime) {
101+
let index = 0;
102+
for (
103+
let time = Math.ceil(startTime);
104+
time <= startTime + TIMELINE_RESOLUTION_SECONDS;
105+
time += TIME_MARKER_INTERVAL_SECONDS, index++
106+
) {
107+
const marker = this.#markerAt(index);
108+
const position = ((time - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX;
109+
marker.style.left = `${position}%`;
110+
marker.textContent = formatTimeMarker(time);
111+
marker.style.display = '';
112+
}
113+
this.#hidePooledFrom(this.#markerPool, index);
114+
}
115+
116+
#renderEvents(startTime, endTime) {
117+
let index = 0;
118+
this.#currentSubtitles.forEach(subtitle => {
119+
if (subtitle.endTime <= startTime || subtitle.startTime >= endTime) {
120+
return;
121+
}
122+
123+
const leftPercent = Math.max(0, ((subtitle.startTime - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX);
124+
const rightPercent = Math.min(PERCENT_MAX, ((subtitle.endTime - startTime) / TIMELINE_RESOLUTION_SECONDS) * PERCENT_MAX);
125+
126+
const element = this.#eventAt(index++);
127+
element.style.left = `${leftPercent}%`;
128+
element.style.width = `${rightPercent - leftPercent}%`;
129+
element.textContent = subtitle.text;
130+
element.title = subtitle.text;
131+
element.style.display = '';
132+
});
133+
this.#hidePooledFrom(this.#eventPool, index);
134+
}
135+
136+
#markerAt(index) {
137+
let marker = this.#markerPool[index];
138+
if (!marker) {
139+
marker = document.createElement('div');
140+
marker.classList.add('timelineMarker');
141+
this.#timelineRuler.appendChild(marker);
142+
this.#markerPool[index] = marker;
143+
}
144+
return marker;
145+
}
146+
147+
#eventAt(index) {
148+
let element = this.#eventPool[index];
149+
if (!element) {
150+
element = document.createElement('div');
151+
element.classList.add('subtitleEvent');
152+
this.#eventsContainer.appendChild(element);
153+
this.#eventPool[index] = element;
154+
}
155+
return element;
156+
}
157+
158+
#hidePooledFrom(pool, fromIndex) {
159+
for (let index = fromIndex; index < pool.length; index++) {
160+
pool[index].style.display = 'none';
161+
}
162+
}
163+
}
164+
15165
class OffsetController {
16-
constructor(player, slider, textField) {
166+
constructor(player, slider, textField, onOffsetChange) {
17167
this.player = player;
18168
this.slider = slider;
19169
this.textField = textField;
170+
this.onOffsetChange = onOffsetChange;
20171

21172
this.#initSlider();
22173
this.#initTextField();
@@ -37,6 +188,8 @@ class OffsetController {
37188

38189
playbackManager.setSubtitleOffset(value, this.player);
39190
this.textField.updateOffset(value);
191+
192+
this.onOffsetChange?.(value);
40193
}
41194

42195
#initSlider() {
@@ -116,15 +269,27 @@ class SubtitleSync {
116269
this.player = currentPlayer;
117270
this.#initUI();
118271

119-
// Create the offset controller
272+
this.timeline = new SubtitleTimeline(
273+
this.timelineRuler,
274+
this.subtitleEventsContainer,
275+
this.subtitleTimelineWrapper,
276+
this.player
277+
);
278+
120279
this.offsetController = new OffsetController(
121280
this.player,
122281
this.subtitleSyncSlider,
123-
this.subtitleSyncTextField
282+
this.subtitleSyncTextField,
283+
() => this.#onOffsetChange()
124284
);
125285
}
126286

127287
destroy() {
288+
if (this.timeline) {
289+
this.timeline.destroy();
290+
this.timeline = null;
291+
}
292+
128293
this.toggle('forceToHide');
129294
if (this.player) {
130295
playbackManager.disableShowingSubtitleOffset(this.player);
@@ -159,6 +324,12 @@ class SubtitleSync {
159324
}
160325
}
161326

327+
#onOffsetChange() {
328+
// The player applies the offset to its track events asynchronously, so
329+
// wait briefly before re-reading them to redraw the timeline.
330+
setTimeout(() => this.timeline?.updateEvents(), OFFSET_APPLY_DELAY_MS);
331+
}
332+
162333
#initUI() {
163334
const parent = document.createElement('div');
164335
document.body.appendChild(parent);
@@ -170,6 +341,9 @@ class SubtitleSync {
170341
this.subtitleSyncTextField = parent.querySelector('.subtitleSyncTextField');
171342
this.subtitleSyncCloseButton = parent.querySelector('.subtitleSync-closeButton');
172343
this.subtitleSyncContainer = parent.querySelector('.subtitleSyncContainer');
344+
this.timelineRuler = parent.querySelector('.timelineRuler');
345+
this.subtitleEventsContainer = parent.querySelector('.subtitleEventsContainer');
346+
this.subtitleTimelineWrapper = parent.querySelector('.subtitleTimelineWrapper');
173347

174348
this.#setupCloseButton();
175349

@@ -199,6 +373,9 @@ class SubtitleSync {
199373

200374
// show subtitle sync
201375
this.subtitleSyncContainer.classList.remove('hide');
376+
377+
// Draw the timeline for the current subtitle track
378+
this.timeline.updateEvents();
202379
}
203380

204381
#canShowSubtitleSync() {

0 commit comments

Comments
 (0)