Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Fast-Forward/Rewind button components #623

Merged
merged 15 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Automate release on every PR merge to develop

### Added
- `QuickSeekButton` control bar component for jumping +/- a configurable number of seconds (10 second default)

## [3.52.2] - 2023-11-23

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions assets/skin-modern/images/quickseek-fastforward.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions assets/skin-modern/images/quickseek-rewind.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/scss/skin-modern/_skin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@import 'components/controlbar';
@import 'components/button';
@import 'components/playbacktogglebutton';
@import 'components/quickseekbutton';
@import 'components/fullscreentogglebutton';
@import 'components/vrtogglebutton';
@import 'components/volumetogglebutton';
Expand Down
18 changes: 18 additions & 0 deletions src/scss/skin-modern/components/_quickseekbutton.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@import '../variables';
@import '../mixins';

.#{$prefix}-ui-quickseekbutton {
@extend %ui-button;

&:hover {
@include svg-icon-shadow;
}

&[data-#{$prefix}-seek-direction='forward'] {
background-image: url('../../assets/skin-modern/images/quickseek-fastforward.svg');
}

&[data-#{$prefix}-seek-direction='rewind'] {
background-image: url('../../assets/skin-modern/images/quickseek-rewind.svg');
}
Comment on lines +11 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need any specific styling for the mobile UI variant?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO there is nothing special needed for now.

}
4 changes: 4 additions & 0 deletions src/ts/components/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface ButtonConfig extends ComponentConfig {
* The text as string or localize callback on the button.
*/
text?: LocalizableText;
/**
* WCAG20 standard for defining info about the component (usually the name)
*/
ariaLabel?: LocalizableText;
}

/**
Expand Down
129 changes: 129 additions & 0 deletions src/ts/components/quickseekbutton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Button, ButtonConfig } from './button';
import { i18n } from '../localization/i18n';
import { PlayerAPI, SeekEvent, TimeShiftEvent } from 'bitmovin-player';
import { UIInstanceManager } from '../uimanager';
import { PlayerUtils } from '../playerutils';

export interface QuickSeekButtonConfig extends ButtonConfig {
/**
* Specify how many seconds the player should seek forward/backwards in the stream.
* Negative values mean a backwards seek, positive values mean a forward seek.
* Default is -10.
*/
seekSeconds?: number;
}

export class QuickSeekButton extends Button<QuickSeekButtonConfig> {
private currentSeekTarget: number | null;
private player: PlayerAPI;

constructor(config: QuickSeekButtonConfig = {}) {
super(config);
this.currentSeekTarget = null;

this.config = this.mergeConfig(
config,
{
seekSeconds: -10,
cssClass: 'ui-quickseekbutton',
},
this.config,
);

const seekDirection = this.config.seekSeconds < 0 ? 'rewind' : 'forward';

this.config.text = this.config.text || i18n.getLocalizer(`quickseek.${seekDirection}`);
this.config.ariaLabel = this.config.ariaLabel || i18n.getLocalizer(`quickseek.${seekDirection}`);

this.getDomElement().data(this.prefixCss('seek-direction'), seekDirection);
}

configure(player: PlayerAPI, uimanager: UIInstanceManager): void {
super.configure(player, uimanager);
this.player = player;

let isLive: boolean;
let hasTimeShift: boolean;

const switchVisibility = (isLive: boolean, hasTimeShift: boolean) => {
if (isLive && !hasTimeShift) {
this.hide();
} else {
this.show();
}
};

const timeShiftDetector = new PlayerUtils.TimeShiftAvailabilityDetector(player);
timeShiftDetector.onTimeShiftAvailabilityChanged.subscribe(
(sender, args: PlayerUtils.TimeShiftAvailabilityChangedArgs) => {
hasTimeShift = args.timeShiftAvailable;
switchVisibility(isLive, hasTimeShift);
},
);

let liveStreamDetector = new PlayerUtils.LiveStreamDetector(player, uimanager);
liveStreamDetector.onLiveChanged.subscribe((sender, args: PlayerUtils.LiveStreamDetectorEventArgs) => {
isLive = args.live;
switchVisibility(isLive, hasTimeShift);
});

// Initial detection
timeShiftDetector.detect();
liveStreamDetector.detect();

this.onClick.subscribe(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably out of scope, but it would be great if these would also trigger the seeks:

  • Pressing Left/Right arrow key
  • Double-tapping the left/right half of the video on mobile

Maybe for a follow-up :)

Copy link
Member Author

@dweinber dweinber May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I think this is out of scope.

  • Pressing Left/Right arrow key

I agree, but then this might interfere with the functionality we already have in

case UIUtils.KeyCode.LeftArrow: {
controls.left();
e.preventDefault();
break;
}
case UIUtils.KeyCode.RightArrow: {
controls.right();
e.preventDefault();
break;
}

  • Double-tapping the left/right half of the video on mobile

I think this and/or a "HugeQuickSeekButton" for displaying within the video would be separate components (similar to PlaybackToggleButton and HugePlaybackToggleButton.
This is also the reason I explicitly mention in the PR description that this is a control bar component 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, makes sense. Just wanted to note it down :D

if (isLive && !hasTimeShift) {
// If no DVR window is available, the button should be hidden anyway, so this is to be absolutely sure
return;
}

if (isLive && this.config.seekSeconds > 0 && player.getTimeShift() === 0) {
// Don't do anything if the player is already on the live edge
return;
}

const currentPosition =
this.currentSeekTarget !== null
? this.currentSeekTarget
: isLive
? player.getTimeShift()
: player.getCurrentTime();

const newSeekTime = currentPosition + this.config.seekSeconds;

if (isLive) {
const clampedValue = PlayerUtils.clampValueToRange(newSeekTime, player.getMaxTimeShift(), 0);
player.timeShift(clampedValue);
} else {
const clampedValue = PlayerUtils.clampValueToRange(newSeekTime, 0, player.getDuration());
player.seek(clampedValue);
}
});

this.player.on(this.player.exports.PlayerEvent.Seek, this.onSeek);
this.player.on(this.player.exports.PlayerEvent.Seeked, this.onSeekedOrTimeShifted);
this.player.on(this.player.exports.PlayerEvent.TimeShift, this.onTimeShift);
this.player.on(this.player.exports.PlayerEvent.TimeShifted, this.onSeekedOrTimeShifted);
}

private onSeek = (event: SeekEvent): void => {
this.currentSeekTarget = event.seekTarget;
};

private onSeekedOrTimeShifted = () => {
this.currentSeekTarget = null;
};

private onTimeShift = (event: TimeShiftEvent): void => {
this.currentSeekTarget = this.player.getTimeShift() + (event.target - event.position);
}

release(): void {
this.player.off(this.player.exports.PlayerEvent.Seek, this.onSeek);
this.player.off(this.player.exports.PlayerEvent.Seeked, this.onSeekedOrTimeShifted);
this.player.off(this.player.exports.PlayerEvent.TimeShift, this.onTimeShift);
this.player.off(this.player.exports.PlayerEvent.TimeShifted, this.onSeekedOrTimeShifted);
this.currentSeekTarget = null;
this.player = null;
}
}
1 change: 1 addition & 0 deletions src/ts/components/replaybutton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class ReplayButton extends Button<ButtonConfig> {
this.config = this.mergeConfig(config, {
cssClass: 'ui-replaybutton',
text: i18n.getLocalizer('replay'),
ariaLabel: i18n.getLocalizer('replay'),
}, this.config);
}

Expand Down
3 changes: 3 additions & 0 deletions src/ts/demofactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {SettingsPanelPage} from './components/settingspanelpage';
import { UIFactory } from './uifactory';
import { UIConfig } from './uiconfig';
import { PlayerAPI } from 'bitmovin-player';
import { QuickSeekButton } from './main';

export namespace DemoFactory {

Expand Down Expand Up @@ -118,6 +119,8 @@ export namespace DemoFactory {
new Container({
components: [
new PlaybackToggleButton(),
new QuickSeekButton({ seekSeconds: -10 }),
new QuickSeekButton({ seekSeconds: 10 }),
new VolumeToggleButton(),
new VolumeSlider(),
new Spacer(),
Expand Down
4 changes: 3 additions & 1 deletion src/ts/localization/languages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,7 @@
"seekBar": "Video-Timeline",
"seekBar.value": "Wert",
"seekBar.timeshift": "Timeshift",
"seekBar.durationText": "aus"
"seekBar.durationText": "aus",
"quickseek.forward": "Vor",
"quickseek.rewind": "Zurück"
}
4 changes: 3 additions & 1 deletion src/ts/localization/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,7 @@
"seekBar": "Video timeline",
"seekBar.value": "Value",
"seekBar.timeshift": "Timeshift",
"seekBar.durationText": "out of"
"seekBar.durationText": "out of",
"quickseek.forward": "Fast Forward",
"quickseek.rewind": "Rewind"
}
4 changes: 3 additions & 1 deletion src/ts/localization/languages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,7 @@
"seekBar": "Línea de Tiempo",
"seekBar.value": "posición",
"seekBar.timeshift": "cambio de posición",
"seekBar.durationText": "de"
"seekBar.durationText": "de",
"quickseek.forward": "Adelantar",
"quickseek.rewind": "Rebobinar"
}
1 change: 1 addition & 0 deletions src/ts/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export { SettingsPanelPageOpenButton } from './components/settingspanelpageopenb
export { SubtitleSettingsPanelPage } from './components/subtitlesettings/subtitlesettingspanelpage';
export { SettingsPanelItem } from './components/settingspanelitem';
export { ReplayButton } from './components/replaybutton';
export { QuickSeekButton, QuickSeekButtonConfig } from './components/quickseekbutton';

// Object.assign polyfill for ES5/IE9
// https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
Expand Down
6 changes: 6 additions & 0 deletions src/ts/playerutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,10 @@ export namespace PlayerUtils {
return this.liveChangedEvent.getEvent();
}
}

export function clampValueToRange(value: number, boundary1: number, boundary2: number): number {
const lowerBoundary = Math.min(boundary1, boundary2);
const upperBoundary = Math.max(boundary1, boundary2);
return Math.min(Math.max(value, lowerBoundary), upperBoundary);
}
}
Loading