@@ -2,21 +2,172 @@ import { playbackManager } from '../playback/playbackmanager';
22import layoutManager from '../layoutManager' ;
33import template from './subtitlesync.template.html' ;
44import './subtitlesync.scss' ;
5+ import { PlaybackSubscriber } from '../../apps/stable/features/playback/utils/playbackSubscriber' ;
56
67// Constants
78const 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
923function 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+
15165class 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