Skip to content

Commit 77c99d2

Browse files
authored
feat: Improve SmartTV scrubbing behavior (#8988)
1 parent 5b9795d commit 77c99d2

File tree

7 files changed

+309
-22
lines changed

7 files changed

+309
-22
lines changed

src/js/control-bar/progress-control/play-progress-bar.js

+12-4
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,25 @@ class PlayProgressBar extends Component {
5656
* @param {number} seekBarPoint
5757
* A number from 0 to 1, representing a horizontal reference point
5858
* from the left edge of the {@link SeekBar}
59+
*
60+
* @param {Event} [event]
61+
* The `timeupdate` event that caused this function to run.
5962
*/
60-
update(seekBarRect, seekBarPoint) {
63+
update(seekBarRect, seekBarPoint, event) {
6164
const timeTooltip = this.getChild('timeTooltip');
6265

6366
if (!timeTooltip) {
6467
return;
6568
}
6669

67-
const time = (this.player_.scrubbing()) ?
68-
this.player_.getCache().currentTime :
69-
this.player_.currentTime();
70+
// Combined logic: if an event with a valid pendingSeekTime getter exists, use it.
71+
const time = (event &&
72+
event.target &&
73+
typeof event.target.pendingSeekTime === 'function') ?
74+
event.target.pendingSeekTime() :
75+
(this.player_.scrubbing() ?
76+
this.player_.getCache().currentTime :
77+
this.player_.currentTime());
7078

7179
timeTooltip.updateTime(seekBarRect, seekBarPoint, time);
7280
}

src/js/control-bar/progress-control/seek-bar.js

+68-14
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,18 @@ class SeekBar extends Slider {
4444
// Avoid mutating the prototype's `children` array by creating a copy
4545
options.children = [...options.children];
4646

47-
const shouldDisableSeekWhileScrubbingOnMobile = player.options_.disableSeekWhileScrubbingOnMobile && (IS_IOS || IS_ANDROID);
47+
const shouldDisableSeekWhileScrubbing =
48+
(player.options_.disableSeekWhileScrubbingOnMobile && (IS_IOS || IS_ANDROID)) ||
49+
(player.options_.disableSeekWhileScrubbingOnSTV);
4850

4951
// Add the TimeTooltip as a child if we are on desktop, or on mobile with `disableSeekWhileScrubbingOnMobile: true`
50-
if ((!IS_IOS && !IS_ANDROID) || shouldDisableSeekWhileScrubbingOnMobile) {
52+
if ((!IS_IOS && !IS_ANDROID) || shouldDisableSeekWhileScrubbing) {
5153
options.children.splice(1, 0, 'mouseTimeDisplay');
5254
}
5355

5456
super(player, options);
5557

56-
this.shouldDisableSeekWhileScrubbingOnMobile_ = shouldDisableSeekWhileScrubbingOnMobile;
58+
this.shouldDisableSeekWhileScrubbing_ = shouldDisableSeekWhileScrubbing;
5759
this.pendingSeekTime_ = null;
5860

5961
this.setEventHandlers_();
@@ -196,7 +198,7 @@ class SeekBar extends Slider {
196198

197199
// update the progress bar time tooltip with the current time
198200
if (this.bar) {
199-
this.bar.update(Dom.getBoundingClientRect(this.el()), this.getProgress());
201+
this.bar.update(Dom.getBoundingClientRect(this.el()), this.getProgress(), event);
200202
}
201203
});
202204

@@ -233,6 +235,26 @@ class SeekBar extends Slider {
233235
this.player_.currentTime();
234236
}
235237

238+
/**
239+
* Getter and setter for pendingSeekTime.
240+
* Ensures the value is clamped between 0 and duration.
241+
*
242+
* @param {number|null} [time] - Optional. The new pending seek time, can be a number or null.
243+
* @return {number|null} - The current pending seek time.
244+
*/
245+
pendingSeekTime(time) {
246+
if (time !== undefined) {
247+
if (time !== null) {
248+
const duration = this.player_.duration();
249+
250+
this.pendingSeekTime_ = Math.max(0, Math.min(time, duration));
251+
} else {
252+
this.pendingSeekTime_ = null;
253+
}
254+
}
255+
return this.pendingSeekTime_;
256+
}
257+
236258
/**
237259
* Get the percentage of media played so far.
238260
*
@@ -242,8 +264,8 @@ class SeekBar extends Slider {
242264
getPercent() {
243265
// If we have a pending seek time, we are scrubbing on mobile and should set the slider percent
244266
// to reflect the current scrub location.
245-
if (this.pendingSeekTime_) {
246-
return this.pendingSeekTime_ / this.player_.duration();
267+
if (this.pendingSeekTime() !== null) {
268+
return this.pendingSeekTime() / this.player_.duration();
247269
}
248270

249271
const currentTime = this.getCurrentTime_();
@@ -284,7 +306,7 @@ class SeekBar extends Slider {
284306

285307
// Don't pause if we are on mobile and `disableSeekWhileScrubbingOnMobile: true`.
286308
// In that case, playback should continue while the player scrubs to a new location.
287-
if (!this.shouldDisableSeekWhileScrubbingOnMobile_) {
309+
if (!this.shouldDisableSeekWhileScrubbing_) {
288310
this.player_.pause();
289311
}
290312

@@ -351,8 +373,8 @@ class SeekBar extends Slider {
351373
}
352374

353375
// if on mobile and `disableSeekWhileScrubbingOnMobile: true`, keep track of the desired seek point but we won't initiate the seek until 'touchend'
354-
if (this.shouldDisableSeekWhileScrubbingOnMobile_) {
355-
this.pendingSeekTime_ = newTime;
376+
if (this.shouldDisableSeekWhileScrubbing_) {
377+
this.pendingSeekTime(newTime);
356378
} else {
357379
this.userSeek_(newTime);
358380
}
@@ -402,10 +424,10 @@ class SeekBar extends Slider {
402424
this.player_.scrubbing(false);
403425

404426
// If we have a pending seek time, then we have finished scrubbing on mobile and should initiate a seek.
405-
if (this.pendingSeekTime_) {
406-
this.userSeek_(this.pendingSeekTime_);
427+
if (this.pendingSeekTime() !== null) {
428+
this.userSeek_(this.pendingSeekTime());
407429

408-
this.pendingSeekTime_ = null;
430+
this.pendingSeekTime(null);
409431
}
410432

411433
/**
@@ -425,18 +447,46 @@ class SeekBar extends Slider {
425447
}
426448
}
427449

450+
/**
451+
* Handles pending seek time when `disableSeekWhileScrubbingOnSTV` is enabled.
452+
*
453+
* @param {number} stepAmount - The number of seconds to step (positive for forward, negative for backward).
454+
*/
455+
handlePendingSeek_(stepAmount) {
456+
if (!this.player_.paused()) {
457+
this.player_.pause();
458+
}
459+
460+
const currentPos = this.pendingSeekTime() !== null ?
461+
this.pendingSeekTime() :
462+
this.player_.currentTime();
463+
464+
this.pendingSeekTime(currentPos + stepAmount);
465+
this.player_.trigger({ type: 'timeupdate', target: this, manuallyTriggered: true });
466+
}
467+
428468
/**
429469
* Move more quickly fast forward for keyboard-only users
430470
*/
431471
stepForward() {
432-
this.userSeek_(this.player_.currentTime() + this.options().stepSeconds);
472+
// if `disableSeekWhileScrubbingOnSTV: true`, keep track of the desired seek point but we won't initiate the seek
473+
if (this.shouldDisableSeekWhileScrubbing_) {
474+
this.handlePendingSeek_(this.options().stepSeconds);
475+
} else {
476+
this.userSeek_(this.player_.currentTime() + this.options().stepSeconds);
477+
}
433478
}
434479

435480
/**
436481
* Move more quickly rewind for keyboard-only users
437482
*/
438483
stepBack() {
439-
this.userSeek_(this.player_.currentTime() - this.options().stepSeconds);
484+
// if `disableSeekWhileScrubbingOnSTV: true`, keep track of the desired seek point but we won't initiate the seek
485+
if (this.shouldDisableSeekWhileScrubbing_) {
486+
this.handlePendingSeek_(-this.options().stepSeconds);
487+
} else {
488+
this.userSeek_(this.player_.currentTime() - this.options().stepSeconds);
489+
}
440490
}
441491

442492
/**
@@ -448,6 +498,10 @@ class SeekBar extends Slider {
448498
*
449499
*/
450500
handleAction(event) {
501+
if (this.pendingSeekTime() !== null) {
502+
this.userSeek_(this.pendingSeekTime());
503+
this.pendingSeekTime(null);
504+
}
451505
if (this.player_.paused()) {
452506
this.player_.play();
453507
} else {

src/js/control-bar/time-controls/current-time-display.js

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class CurrentTimeDisplay extends TimeDisplay {
3535

3636
if (this.player_.ended()) {
3737
time = this.player_.duration();
38+
} else if (event && event.target && typeof event.target.pendingSeekTime === 'function') {
39+
time = event.target.pendingSeekTime();
3840
} else {
3941
time = (this.player_.scrubbing()) ? this.player_.getCache().currentTime : this.player_.currentTime();
4042
}

src/js/player.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -5577,7 +5577,8 @@ Player.prototype.options_ = {
55775577
},
55785578
// Default smooth seeking to false
55795579
enableSmoothSeeking: false,
5580-
disableSeekWhileScrubbingOnMobile: false
5580+
disableSeekWhileScrubbingOnMobile: false,
5581+
disableSeekWhileScrubbingOnSTV: false
55815582
};
55825583

55835584
TECH_EVENTS_RETRIGGER.forEach(function(event) {

src/js/slider/slider.js

+4
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ class Slider extends Component {
325325
event.stopPropagation();
326326
this.stepForward();
327327
} else {
328+
if (this.pendingSeekTime()) {
329+
this.pendingSeekTime(null);
330+
this.userSeek_(this.player_.currentTime());
331+
}
328332
super.handleKeyDown(event);
329333
}
330334

test/unit/controls.test.js

+113-2
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,11 @@ QUnit.test('Seek bar percent should represent scrub location if we are scrubbing
320320
const seekBar = player.controlBar.progressControl.seekBar;
321321

322322
player.duration(100);
323-
seekBar.pendingSeekTime_ = 20;
323+
seekBar.pendingSeekTime(20);
324324

325325
assert.equal(seekBar.getPercent(), 0.2, 'seek bar percent set correctly to pending seek time');
326326

327-
seekBar.pendingSeekTime_ = 50;
327+
seekBar.pendingSeekTime(50);
328328

329329
assert.equal(seekBar.getPercent(), 0.5, 'seek bar percent set correctly to next pending seek time');
330330
});
@@ -680,3 +680,114 @@ QUnit.test('Remaing time negative sign can be optional', function(assert) {
680680
rtd2.dispose();
681681
player.dispose();
682682
});
683+
684+
QUnit.module('SmartTV UI Updates (Progress Bar & Time Display)', function(hooks) {
685+
let player;
686+
let seekBar;
687+
let currentTimeDisplay;
688+
689+
hooks.beforeEach(function() {
690+
player = TestHelpers.makePlayer({
691+
spatialNavigation: { enabled: true },
692+
disableSeekWhileScrubbingOnSTV: true,
693+
controlBar: {
694+
progressControl: {
695+
seekBar: {
696+
stepSeconds: 5
697+
}
698+
}
699+
}
700+
});
701+
702+
seekBar = player.controlBar.progressControl.seekBar;
703+
currentTimeDisplay = player.controlBar.getChild('currentTimeDisplay');
704+
705+
player.duration(100);
706+
});
707+
708+
hooks.afterEach(function() {
709+
player.dispose();
710+
});
711+
712+
QUnit.test('Step forward updates seek bar progress and current-time display', function(assert) {
713+
player.currentTime(40);
714+
seekBar.stepForward();
715+
716+
assert.equal(
717+
seekBar.pendingSeekTime(),
718+
45,
719+
'pendingSeekTime should be 45 (40 + 5) after stepForward'
720+
);
721+
722+
assert.equal(
723+
seekBar.getPercent(),
724+
0.45,
725+
'Seek bar progress should reflect 45% progress after stepForward'
726+
);
727+
728+
assert.equal(
729+
currentTimeDisplay.formattedTime_,
730+
'0:45',
731+
'Current-time-display should update to 45s after stepForward'
732+
);
733+
});
734+
735+
QUnit.test('Step back updates seek bar progress and current-time display', function(assert) {
736+
player.currentTime(40);
737+
seekBar.stepBack();
738+
739+
assert.equal(
740+
seekBar.pendingSeekTime(),
741+
35,
742+
'pendingSeekTime should be 35 (40 - 5) after stepBack'
743+
);
744+
745+
assert.equal(
746+
seekBar.getPercent(),
747+
0.35,
748+
'Seek bar progress should reflect 35% progress after stepBack'
749+
);
750+
751+
assert.equal(
752+
currentTimeDisplay.formattedTime_,
753+
'0:35',
754+
'Current-time-display should update to 35s after stepBack'
755+
);
756+
});
757+
758+
QUnit.test('Pressing enter finalizes the seek and updates UI', function(assert) {
759+
player.currentTime(40);
760+
seekBar.stepForward();
761+
762+
seekBar.handleAction();
763+
764+
assert.equal(
765+
seekBar.pendingSeekTime(),
766+
null,
767+
'pendingSeekTime should be reset to null after seeking'
768+
);
769+
770+
assert.equal(
771+
seekBar.getPercent(),
772+
0.45,
773+
'Seek bar progress should remain at 45% after seeking'
774+
);
775+
776+
assert.equal(
777+
currentTimeDisplay.formattedTime_,
778+
'0:45',
779+
'Current-time-display should remain at 45s after seeking'
780+
);
781+
});
782+
783+
QUnit.test('Resets pendingSeekTime when SmartTV focus moves away without confirmation', function(assert) {
784+
const userSeekSpy = sinon.spy(seekBar, 'userSeek_');
785+
786+
seekBar.trigger({ type: 'keydown', key: 'ArrowUp' });
787+
assert.ok(seekBar.pendingSeekTime() !== null, 'pendingSeekTime should be set after ArrowUp keydown');
788+
seekBar.trigger({ type: 'keydown', key: 'ArrowLeft' });
789+
assert.equal(seekBar.pendingSeekTime(), null, 'pendingSeekTime should be reset when SeekBar loses focus');
790+
assert.ok(userSeekSpy.calledWith(player.currentTime()), 'userSeek_ should be called with current player time');
791+
userSeekSpy.restore();
792+
});
793+
});

0 commit comments

Comments
 (0)