From 88cb5a7cfbf9b532a1710fdd23a799f14d15625a Mon Sep 17 00:00:00 2001 From: Artur Finger Date: Mon, 8 Apr 2024 13:12:08 +0300 Subject: [PATCH 1/2] DE-7017: Generic improvements (#37) * FEATURES Add option to configure visibility of player controls and to fully hide them. Make base theme configurable: audio controls, full screen button, track controls. Add support for UI cues. Add support for companion component above the bottom bar of player controls. * FIXES Fix rounding of video duration. Now all values with remainder 0.8 and higher are rounded UP instead of down, to produce a more accurate value. * REFACTOR Remove unnecessary private annotations. Cleanup: detach listeners from PRESTO instance. Allow presto instance to be null. * APP Move index.html to root folder. Add mux.js to support HLS playback. * TOOLING Add prepack script, this way it is easy to pack TGZ NPM packages for local use. The ESLint rule is getting miss-reported `@typescript-eslint/no-unsafe-assignment`, I'm not sure why, but let's just ignore it for now. --- .eslintrc.js | 1 + CHANGELOG.md | 16 ++ app/index.html | 16 ++ app/src/index.html | 15 -- package.json | 3 +- scripts/rollup.config.app.mjs | 2 +- src/Player.ts | 246 ++++++++++++------- src/components/BaseThemeOverlay.tsx | 69 ++++-- src/components/Duration.tsx | 2 +- src/components/MuteButton.tsx | 2 +- src/components/PlayerControls.tsx | 2 +- src/components/PlayerSurface.tsx | 6 +- src/components/SeekBar.tsx | 47 +++- src/components/SeekBarCues.tsx | 30 +++ src/components/Slider.tsx | 10 +- src/components/StartButton.tsx | 2 +- src/components/Thumbnail.tsx | 1 + src/components/VuMeter.tsx | 38 ++- src/context/PrestoContext.ts | 5 +- src/hooks/hooks.ts | 14 +- src/react.ts | 4 +- src/services/controls.ts | 5 +- src/services/fullscreen.ts | 1 - src/services/volumeMeterService.ts | 4 +- src/themes/pp-ui-base-theme.css | 45 ++++ src/types.ts | 19 ++ src/utils.ts | 8 +- story/stories/components/VuMeter.stories.tsx | 1 - story/stories/prep.tsx | 1 - tests/testUtils.tsx | 1 - 30 files changed, 454 insertions(+), 162 deletions(-) create mode 100644 app/index.html delete mode 100644 app/src/index.html create mode 100644 src/components/SeekBarCues.tsx create mode 100644 src/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index d15cd9a..5285fee 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { //
) => event.altKey}}/> "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", }, overrides: [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 447fd05..341f627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 0.8.3 (Beta) + +## New Features + +* Add option to keep player controls hidden. +* Make `BaseThemeOverlay` more configurable: + * Add option to hide buttons (audio, full screen, track options) + * Add option to hide the top bar of player controls + * Add option to render a companion component above the bottom bar + of player controls +* Add option to display cues on the seek bar + +## Fixes + +* Fix rounding of duration. + # 0.7.3 (Beta) ## Fixes diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..5d17cb8 --- /dev/null +++ b/app/index.html @@ -0,0 +1,16 @@ + + + + + + + PRESTOplay React Components + + + +
+ + + diff --git a/app/src/index.html b/app/src/index.html deleted file mode 100644 index 0a12e85..0000000 --- a/app/src/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - PRESTOplay React Components - - - -
- - - diff --git a/package.json b/package.json index 6950647..d900286 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test:watch": "jest --watch", "lint": "npx eslint .", "storybook": "storybook dev --port 6006", - "build-storybook": "storybook build --output-dir ./dist/storybook" + "build-storybook": "storybook build --output-dir ./dist/storybook", + "prepack": "npm run build" }, "license": "Apache-2.0", "devDependencies": { diff --git a/scripts/rollup.config.app.mjs b/scripts/rollup.config.app.mjs index 5f745e8..3a71287 100644 --- a/scripts/rollup.config.app.mjs +++ b/scripts/rollup.config.app.mjs @@ -49,7 +49,7 @@ const options = [ open: true, verbose: true, contentBase: ["", "app", "app/src"], - host: "0.0.0.0", + host: "localhost", port: "3000", }), livereload({ diff --git a/src/Player.ts b/src/Player.ts index 02e7d72..d445a2b 100644 --- a/src/Player.ts +++ b/src/Player.ts @@ -13,6 +13,7 @@ import { TrackType, } from './Track' import { defaultTrackLabel, defaultTrackSorter, TrackLabeler, TrackLabelerOptions, TrackSorter } from './TrackLabeler' +import { Cue, Disposer } from './types' /** * The player initializer is a function that receives the presto play instance @@ -182,6 +183,10 @@ export interface UIEvents { * Unset, Idle, and Error states. */ enabled: boolean + /** + * Event triggered when timeline/seek bar cues change. + */ + cuesChanged: Cue[] } /** @@ -201,153 +206,120 @@ const isEnabledState = (state: State): boolean => { export class Player { /** * The player instance - * @private */ - private pp_: clpp.Player | null = null + protected pp_: clpp.Player | null = null /** * We maintain a queue of actions that will be posted towards the player * instance once it is initialized - * - * @private */ private _actionQueue_: Action[] = [] /** * Function that resolves the player initialization - * - * @private */ private _actionQueueResolved?: () => void /** * A promise that resolves once the player is initialized - * @private */ private readonly _actionQueuePromise: Promise /** * The player initializer - * @private */ private readonly _initializer?: PlayerInitializer - /** * Internal state that indicates that the "controls" are visible - * - * @private */ private _controlsVisible = false /** * Internal controls that indicate that the slide in menu is visible - * @private */ private _slideInMenuVisible = false /** * The currently playing video track - * - * @private */ private _playingVideoTrack: Track | undefined - /** * The currently selected video track - * - * @private */ private _videoTrack: Track = getUnavailableTrack('video') /** * The currently selected audio track - * @private */ private _audioTrack: Track = getUnavailableTrack('audio') /** * The currently selected text track - * - * @private */ private _textTrack: Track = getUnavailableTrack('text') - /** * All available video tracks - * - * @private */ private _videoTracks: Track[] = [] /** * All available audio tracks - * - * @private */ private _audioTracks: Track[] = [] - /** * All available text tracks - * - * @private */ private _textTracks: Track[] = [] - /** * The track sorter - * - * @private */ private _trackSorter: TrackSorter = defaultTrackSorter - /** * The track labeler - * - * @private */ private _trackLabeler: TrackLabeler = defaultTrackLabel - /** * The track labeler options - * @private */ private _trackLabelerOptions?: TrackLabelerOptions - /** * The event emitter for UI related events - * - * @private */ private readonly _eventEmitter = new EventEmitter() - /** * This is true while we are waiting for a user initiated seek even to * complete - * - * @private */ private _isUserSeeking = false - /** * The target of the last user initiated seek event. We use this in case * there were more seek events while we were waiting for the last event * to complete - * - * @private */ private _userSeekingTarget = -1 - /** * Proxy the playback rate - * @private */ private _rate = 1 - /** * The current player configuration - * - * @private */ private _config: clpp.PlayerConfiguration | null = null - /** * Indicate that the config was loaded - * @private */ private _configLoaded = false - + /** + * UI control visibility manager + */ private _controls = new Controls() + /** + * Disposers of listeners on the PRESTOplay player instance + */ + private _prestoDisposers: Disposer[] = [] + /** + * The last playback state + */ + private _lastPlaybackState: State = State.Unset + /** + * If true state changes to "ended" state should be ignored + */ + private _ignoreStateEnded = false + /** + * Timeline cues + */ + private _cues: Cue[] = [] constructor(initializer?: PlayerInitializer) { this._initializer = initializer @@ -375,7 +347,40 @@ export class Player { this.pp_ = new clpp.Player(element, baseConfig) - const handlePlayerTracksChanged = (type?: TrackType) => { + this.attachPrestoListeners_(this.pp_) + + if (this._initializer) { + this._initializer(this.pp_) + } + for (let i = 0; i < this._actionQueue_.length; i++) { + await this._actionQueue_[i]() + } + this._actionQueue_ = [] + if (this._actionQueueResolved) { + this._actionQueueResolved() + } + } + + /** + * Set timeline cues + */ + setCues(cues: Cue[]) { + this._cues = cues + this.emitUIEvent('cuesChanged', cues) + } + + /** + * Get timeline cues + */ + getCues(): Cue[] { + return this._cues + } + + /** + * Attach listeners to PRESTOplay events + */ + protected attachPrestoListeners_(player: clpp.Player) { + const createTrackChangeHandler = (type?: TrackType) => { return () => { const trackManager = this.trackManager if (!trackManager) {return} @@ -395,12 +400,16 @@ export class Player { } } - this.pp_.on(clpp.events.TRACKS_ADDED, handlePlayerTracksChanged()) - this.pp_.on(clpp.events.AUDIO_TRACK_CHANGED, handlePlayerTracksChanged('audio')) - this.pp_.on(clpp.events.VIDEO_TRACK_CHANGED, handlePlayerTracksChanged('video')) - this.pp_.on(clpp.events.TEXT_TRACK_CHANGED, handlePlayerTracksChanged('text')) + const onTracksAdded = createTrackChangeHandler() + const onAudioTrackChanged = createTrackChangeHandler('audio') + const onVideoTrackChanged = createTrackChangeHandler('video') + const onTextTrackChanged = createTrackChangeHandler('text') + player.on(clpp.events.TRACKS_ADDED, onTracksAdded) + player.on(clpp.events.AUDIO_TRACK_CHANGED, onAudioTrackChanged) + player.on(clpp.events.VIDEO_TRACK_CHANGED, onVideoTrackChanged) + player.on(clpp.events.TEXT_TRACK_CHANGED, onTextTrackChanged) - this.pp_.on(clpp.events.STATE_CHANGED, (event: any) => { + const onStateChanged = (event: any) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const e = event // eslint-disable-next-line @typescript-eslint/no-unsafe-argument @@ -408,16 +417,21 @@ export class Player { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const previousState = toState(e.detail.previousState) + if (this._ignoreStateEnded && currentState === State.Ended) { + return + } + this.emitUIEvent('statechanged', { currentState, previousState, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment reason: e.detail.reason, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment timeSinceLastStateChangeMS: e.detail.timeSinceLastStateChangeMS, }) - if (isEnabledState(currentState) !== isEnabledState(previousState)) { + if ( + isEnabledState(currentState) !== isEnabledState(previousState) + || isEnabledState(currentState) !== isEnabledState(this._lastPlaybackState) + ) { this.emitUIEvent('enabled', isEnabledState(currentState)) } @@ -426,39 +440,47 @@ export class Player { } else { this._controls.unpin() } - }) - this.pp_.on('timeupdate', () => { - const position = this.pp_?.getPosition() + this._lastPlaybackState = currentState + } + player.on(clpp.events.STATE_CHANGED, onStateChanged) + + const onTimeupdate = () => { + const position = player.getPosition() if (position != null) { this.emitUIEvent('position', position) } - }) + } + player.on('timeupdate', onTimeupdate) - this.pp_.on('ratechange', () => { - const ppRate = this.pp_?.getPlaybackRate() + this._rate = player.getPlaybackRate() + const onRateChange = () => { + const ppRate = player.getPlaybackRate() if (ppRate != null && this.state !== State.Buffering) { this._rate = ppRate this.emitUIEvent('ratechange', this.rate) } - }) + } + player.on('ratechange', onRateChange) - this.pp_.on('durationchange', () => { - const duration = this.pp_?.getDuration() + const onDurationChange = () => { + const duration = player.getDuration() if (duration != null) { this.emitUIEvent('durationchange', duration) } - }) + } + player.on('durationchange', onDurationChange) - this.pp_.on('volumechange', () => { + const onVolumeChange = () => { this.emitUIEvent('volumechange', { volume: this.volume, muted: this.muted, }) - }) + } + player.on('volumechange', onVolumeChange) - this.pp_.on(clpp.events.BITRATE_CHANGED, (e: any) => { + const onBitrateChange = (e: any) => { const tm = this.trackManager if (tm) { @@ -466,27 +488,74 @@ export class Player { } else { this.playingVideoTrack = undefined } - handlePlayerTracksChanged('video') + } + player.on(clpp.events.BITRATE_CHANGED, onBitrateChange) + + this._prestoDisposers.push(() => { + player.off(clpp.events.TRACKS_ADDED, onTracksAdded) + player.off(clpp.events.AUDIO_TRACK_CHANGED, onAudioTrackChanged) + player.off(clpp.events.VIDEO_TRACK_CHANGED, onVideoTrackChanged) + player.off(clpp.events.TEXT_TRACK_CHANGED, onTextTrackChanged) + player.off(clpp.events.STATE_CHANGED, onStateChanged) + player.off('timeupdate', onTimeupdate) + player.off('ratechange', onRateChange) + player.off('durationchange', onDurationChange) + player.off('volumechange', onVolumeChange) + player.off(clpp.events.BITRATE_CHANGED, onBitrateChange) }) + } - this._rate = this.pp_.getPlaybackRate() + protected refreshPrestoState_ (player: clpp.Player) { + const state = toState(player.getState()) - if (this._initializer) { - this._initializer(this.pp_) - } - for (let i = 0; i < this._actionQueue_.length; i++) { - await this._actionQueue_[i]() + if (isEnabledState(state)) { + this.emitUIEvent('enabled', true) } - this._actionQueue_ = [] - if (this._actionQueueResolved) { - this._actionQueueResolved() + + this.emitUIEvent('statechanged', { + currentState: state, + previousState: this._lastPlaybackState, + timeSinceLastStateChangeMS: 10, + }) + + const position = player.getPosition() ?? 0 + this.emitUIEvent('position', position) + + const duration = player.getDuration() + if (duration != null) { + this.emitUIEvent('durationchange', duration) } + + this.emitUIEvent('volumechange', { + volume: this.volume, + muted: this.muted, + }) + } + + /** + * Remove listeners to PRESTOplay events + */ + protected removePrestoListeners_ () { + this._prestoDisposers.forEach(dispose => dispose()) + this._prestoDisposers = [] } get trackManager() { return this.pp_?.getTrackManager() ?? null } + /** + * Configure the player to ignore the "ended" state change. This is useful + * for situations where RESTOplay goes to the "ended" state prematurely, which + * sometimes happens (e.g. state changes to ended, but the video still plays + * another 800 ms or so and timeupdates are triggered). + * Not sure if this is a bug in PRESTOplay or a problem with the asset, but + * it happens. + */ + set ignoreStateEnded(value: boolean) { + this._ignoreStateEnded = value + } + /** * Seek to the given position. Calling this function has no effect unless the * presto instance is already initialized. @@ -638,6 +707,7 @@ export class Player { } private async reset_() { + this.removePrestoListeners_() if (this.pp_) { await this.pp_.release() } @@ -889,9 +959,9 @@ export class Player { const isSameTrack = (a?: Track, b?: Track): boolean => { if (a && b) { return a.type === b.type && - a.ppTrack === b.ppTrack && - a.selected === b.selected && - a.id === b.id + a.ppTrack === b.ppTrack && + a.selected === b.selected && + a.id === b.id } return !a && !b diff --git a/src/components/BaseThemeOverlay.tsx b/src/components/BaseThemeOverlay.tsx index e1265cf..0894ab7 100644 --- a/src/components/BaseThemeOverlay.tsx +++ b/src/components/BaseThemeOverlay.tsx @@ -1,5 +1,7 @@ import React from 'react' +import { ControlsVisibilityMode } from '../services/controls' + import { BufferingIndicator } from './BufferingIndicator' import { CurrentTime } from './CurrentTime' import { Duration } from './Duration' @@ -63,6 +65,38 @@ export interface BaseThemeOverlayProps extends BaseComponentProps { * seekbar, 'none' hides the seekbar. */ seekBar?: 'enabled' | 'disabled' | 'none' + /** + * Visibility mode of UI controls. + */ + controlsVisibility?: ControlsVisibilityMode + /** + * If true, audio controls are displayed. Defaults to true. + */ + hasAudioControls?: boolean + /** + * If true, a fullscreen button is displayed. Defaults to true. + */ + hasFullScreenButton?: boolean + /** + * If true, track controls are displayed. Defaults to true. + */ + hasTrackControls?: boolean + /** + * If true, the top controls bar is displayed. Defaults to true. + */ + hasTopControlsBar?: boolean + /** + * Render a custom bottom companion component. + */ + renderBottomCompanion?: () => (JSX.Element | null) + /** + * If true, seek bar cues are shown. Default: true. + */ + showSeekBarCues?: boolean + /** + * Class name for the seek bar slider component. + */ + seekBarSliderClassName?: string } /** @@ -70,6 +104,11 @@ export interface BaseThemeOverlayProps extends BaseComponentProps { */ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { const selectionOptions = props.menuSelectionOptions ?? DEFAULT_SELECTION_OPTIONS + const hasAudioControls = props.hasAudioControls ?? true + const hasTrackControls = props.hasTrackControls ?? true + const hasFullScreenButton = props.hasFullScreenButton ?? true + const hasTopControlsBar = props.hasTopControlsBar ?? true + const showSeekBarCues = props.showSeekBarCues ?? true const renderOptionsMenu = () => { if (selectionOptions.length === 0) {return} @@ -77,23 +116,19 @@ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { } const renderTopBar = () => { - if (selectionOptions.length === 0) {return} + if (!hasTopControlsBar || selectionOptions.length === 0) {return} return ( - + -
- -
+ {hasTrackControls ? ( +
+ +
+ ) : null}
) } - // Some component rendering depends on configuration, and - // we wrap the rendering code into helpers - const renderFullscreenButton = () => { - return - } - const renderPosterImage = () => { if (!props.posterUrl) {return} return @@ -106,7 +141,7 @@ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { return (
- + {/* Top bar */} @@ -119,6 +154,8 @@ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { + {props.renderBottomCompanion?.()} + {/* Bottom bar */}
@@ -134,6 +171,8 @@ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { adjustWithKeyboard={true} enableThumbnailSlider={false} enabled={(props.seekBar ?? 'enabled') === 'enabled'} + showCues={showSeekBarCues} + sliderClassName={props.seekBarSliderClassName} />}
@@ -143,11 +182,11 @@ export const BaseThemeOverlay = (props: BaseThemeOverlayProps) => { - + {hasAudioControls ? : null} - - {renderFullscreenButton()} + {hasAudioControls ? : null} + {hasFullScreenButton ? : null}
diff --git a/src/components/Duration.tsx b/src/components/Duration.tsx index 8cc2aa2..c61b12f 100644 --- a/src/components/Duration.tsx +++ b/src/components/Duration.tsx @@ -15,7 +15,7 @@ const toString = (duration: number) => { if (duration === Infinity) { return 'Live' } - return timeToString(duration, getMinimalFormat(duration)) + return timeToString(duration, getMinimalFormat(duration), 0.2) } export interface DurationProps extends BaseComponentProps { diff --git a/src/components/MuteButton.tsx b/src/components/MuteButton.tsx index 9d5a463..3746154 100644 --- a/src/components/MuteButton.tsx +++ b/src/components/MuteButton.tsx @@ -36,7 +36,7 @@ export const MuteButton = (props: MuteButtonProps) => { }) function toggle(e: React.MouseEvent) { - if (e.defaultPrevented) {return} + if (e.defaultPrevented || !presto) {return} presto.setMuted(!presto.isMuted()) } diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index adb23d3..7479f27 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -45,7 +45,7 @@ export interface PlayerControlsProps extends BaseComponentProps { export const PlayerControls = (props: PlayerControlsProps) => { const [lastFocusIndex, setLastFocusIndex] = useState(-1) const { player } = useContext(PrestoContext) - const controlsVisible = useControlsVisible() + const controlsVisible = useControlsVisible() && props.mode !== 'never' useEffect(() => { if (props.hideDelay) { diff --git a/src/components/PlayerSurface.tsx b/src/components/PlayerSurface.tsx index 6ce87e3..f5de044 100644 --- a/src/components/PlayerSurface.tsx +++ b/src/components/PlayerSurface.tsx @@ -51,8 +51,10 @@ export interface PlayerProps extends BaseComponentProps { } const getContext = (nullableContext: Partial) => { - return Object.values(nullableContext) - .every(value => value != null) ? nullableContext as PrestoContextType : null + if (!nullableContext.playerSurface || !nullableContext.player) { + return null + } + return nullableContext as PrestoContextType } /** diff --git a/src/components/SeekBar.tsx b/src/components/SeekBar.tsx index a72336f..872d82f 100644 --- a/src/components/SeekBar.tsx +++ b/src/components/SeekBar.tsx @@ -1,8 +1,10 @@ import React, { ForwardedRef, forwardRef, useContext, useState } from 'react' import { PrestoContext } from '../context/PrestoContext' +import { useCues } from '../hooks/hooks' import { usePrestoEnabledState, usePrestoUiEvent } from '../react' +import { SeekBarCues } from './SeekBarCues' import { Slider } from './Slider' import { Thumbnail } from './Thumbnail' @@ -16,6 +18,14 @@ export interface SeekBarProps extends BaseComponentProps { keyboardSeekBackward?: number notFocusable?: boolean enabled?: boolean + /** + * If cues should be shown on the timeline. Default: true. + */ + showCues?: boolean + /** + * Class name passed to sub-component. + */ + sliderClassName?: string } const useEnabled = (enabled: boolean) => { @@ -32,14 +42,18 @@ const useEnabled = (enabled: boolean) => { export const SeekBar = forwardRef((props: SeekBarProps, ref: ForwardedRef) => { const { player } = useContext(PrestoContext) const [progress, setProgress] = useState(0) + const [duration, setDuration] = useState(0) const [hoverPosition, setHoverPosition] = useState(-1) const [hoverValue, setHoverValue] = useState(0) const [thumbWidth, setThumbWidth] = useState(0) const enabled = useEnabled(props.enabled ?? true) + const cues = useCues() + const showCues = props.showCues ?? true function updateFromPlayer(position?: number): number { const range = player.seekRange const rangeDuration = range.end - range.start + setDuration(rangeDuration) position = position || player.position const positionInRange = position - range.start const progress = Math.min(100, Math.max(0, 100.0 * (positionInRange / rangeDuration))) @@ -113,26 +127,33 @@ export const SeekBar = forwardRef((props: SeekBarProps, ref: ForwardedRef - +
+ +
{renderThumbnailSlider()} + {showCues && cues.length > 0 ? ( +
+ +
+ ): null}
) }) diff --git a/src/components/SeekBarCues.tsx b/src/components/SeekBarCues.tsx new file mode 100644 index 0000000..84cd3c5 --- /dev/null +++ b/src/components/SeekBarCues.tsx @@ -0,0 +1,30 @@ +import React from 'react' + +import { Cue } from '../types' + +type Props = { + cues: Cue[] + duration: number +} + +/** + * Timeline cues + */ +export const SeekBarCues = (props: Props) => { + const cues = props.cues + return ( +
+
+ {cues.map((cue) => { + const left = (cue.startTime / props.duration) * 100 + return ( +
+ ) + })} +
+
+ ) +} diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx index 468387b..0ff9ec5 100644 --- a/src/components/Slider.tsx +++ b/src/components/Slider.tsx @@ -79,8 +79,6 @@ const getPositionFromMouseEvent = (e: PressEvent, container: HTMLDivElement | nu const width = rect.width let x: number if (e.type === 'touchmove' || e.type === 'touchstart' || e.type === 'touchend') { - // Eslint false alarm - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const touchEvent = e as React.TouchEvent x = touchEvent.changedTouches[touchEvent.changedTouches.length - 1].pageX - rect.left } else { @@ -310,11 +308,13 @@ export const Slider = forwardRef((props: SliderProps, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + const className = `pp-ui-slider ${interacting ? 'pp-ui-slider-interacting' : ''} ` + + `${props.disabled ? 'pp-ui-disabled' : 'pp-ui-enabled'} ${props.className || ''}` + return (
{ >
+ className="pp-ui-slider-range">
Promise + onClick?: () => any | Promise } const isVisibleState = (state: State) => { diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index 503804a..23a234d 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -28,6 +28,7 @@ export interface ThumbnailProps extends BaseComponentProps { */ const usePlugin = () => { const { presto } = useContext(PrestoContext) + if (!presto) {return null} return presto.getPlugin(clpp.thumbnails.ThumbnailsPlugin.Id) as clpp.thumbnails.ThumbnailsPlugin | null } diff --git a/src/components/VuMeter.tsx b/src/components/VuMeter.tsx index ebeb777..883ee1c 100644 --- a/src/components/VuMeter.tsx +++ b/src/components/VuMeter.tsx @@ -11,23 +11,49 @@ export type Props = BaseComponentProps & { height: number } + +const mount = (service: VolumeMeterService, canvas: HTMLCanvasElement, config: VuMeterConfig) => { + service.configure(canvas, config) + service.mount() +} + /** * Volume Unit Meter */ export const VuMeter = (props: Props) => { - const ctx = useContext(PrestoContext) - const serviceRef = useRef(new VolumeMeterService(ctx.presto)) + const { presto } = useContext(PrestoContext) + const canvasRef = useRef(null) + const serviceRef = useRef(null) + const config = props.config ?? {} + + useEffect(() => { + if (presto) { + serviceRef.current = new VolumeMeterService(presto) + if (canvasRef.current) { + mount(serviceRef.current, canvasRef.current, config) + } + } + }, [presto]) + const onRef = useCallback((canvas: HTMLCanvasElement) => { - serviceRef.current.configure(canvas, props.config ?? {}) - serviceRef.current.mount() + canvasRef.current = canvas + if (serviceRef.current) { + mount(serviceRef.current, canvas, config) + } }, []) useEffect(() => { return () => { - serviceRef.current.unmount() + if (serviceRef.current) { + serviceRef.current.unmount() + serviceRef.current = null + canvasRef.current = null + } } }, []) - return + return } diff --git a/src/context/PrestoContext.ts b/src/context/PrestoContext.ts index 68ef55f..a60b0d7 100644 --- a/src/context/PrestoContext.ts +++ b/src/context/PrestoContext.ts @@ -7,18 +7,17 @@ import { Player } from '../Player' export type PrestoContextType = { playerSurface: HTMLDivElement player: Player - presto: clpp.Player + presto: clpp.Player | null } /** * Main context for our React components. */ export const PrestoContext = createContext({ - // @ts-ignore All the values here will be defined when the context is instantiated. + // @ts-ignore Some of the values here will be defined when the context is instantiated. // But I have to specify some default values here anyway, so I'm setting all to null. playerSurface: null, // @ts-ignore player: null, - // @ts-ignore presto: null, }) diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts index e81db94..a9d64f6 100644 --- a/src/hooks/hooks.ts +++ b/src/hooks/hooks.ts @@ -1,6 +1,8 @@ -import { useState } from 'react' +import { useContext, useState } from 'react' +import { PrestoContext } from '../context/PrestoContext' import { usePrestoUiEvent } from '../react' +import { Cue } from '../types' /** * @returns The current hover position as a percentage @@ -15,3 +17,13 @@ export const useHoverPercent = () => { return percent < 0 ? null : percent } + +/** + * @returns Timeline / seekBar cues + */ +export const useCues = (): Cue[] => { + const { player } = useContext(PrestoContext) + const [cues, setCues] = useState(player.getCues()) + usePrestoUiEvent('cuesChanged', setCues) + return cues +} diff --git a/src/react.ts b/src/react.ts index 7e32519..ee558f7 100644 --- a/src/react.ts +++ b/src/react.ts @@ -28,6 +28,8 @@ export function usePrestoCoreEvent( const presto = useContext(PrestoContext).presto ?? presto_ useEffect(() => { + if (!presto) {return} + const handleEvent = (event: Record) => { handler(event, presto) } @@ -37,7 +39,7 @@ export function usePrestoCoreEvent( presto.off(eventName, handleEvent) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventName, ...dependencies]) + }, [presto, eventName, ...dependencies]) } /** diff --git a/src/services/controls.ts b/src/services/controls.ts index dbdbd61..252fe71 100644 --- a/src/services/controls.ts +++ b/src/services/controls.ts @@ -1,9 +1,12 @@ -export type ControlsVisibilityMode = 'auto' | 'always-visible' +export type ControlsVisibilityMode = 'auto' | 'always-visible' | 'never' type Callback = (visible: boolean) => void const AUTO_HIDE_DELAY_MS = 3_000 +/** + * A helper for hiding and showing UI controls. + */ export class Controls { public onChange: Callback = () => {} public hideDelayMs = AUTO_HIDE_DELAY_MS diff --git a/src/services/fullscreen.ts b/src/services/fullscreen.ts index 4c2643a..825b0fb 100644 --- a/src/services/fullscreen.ts +++ b/src/services/fullscreen.ts @@ -1,5 +1,4 @@ /* eslint-disable - @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/ban-ts-comment */ diff --git a/src/services/volumeMeterService.ts b/src/services/volumeMeterService.ts index 7333899..12297ad 100644 --- a/src/services/volumeMeterService.ts +++ b/src/services/volumeMeterService.ts @@ -1,5 +1,7 @@ import { clpp } from '@castlabs/prestoplay' +import type { Disposer } from '../types' + type GradientColorStop = { stop: number color: string @@ -64,7 +66,7 @@ export class VolumeMeterService { private audioSource: AudioNode | null = null private canvas: HTMLCanvasElement | null = null private config: ConfigInternal = DEFAULT_CONFIG - private disposers: (() => void)[] = [] + private disposers: Disposer[] = [] private enabled = false private mediaElementToSourceNodeMap: Map = new Map() private log = new clpp.log.Logger('clpp.services.VuMeter') diff --git a/src/themes/pp-ui-base-theme.css b/src/themes/pp-ui-base-theme.css index 69425cc..716adde 100644 --- a/src/themes/pp-ui-base-theme.css +++ b/src/themes/pp-ui-base-theme.css @@ -18,6 +18,8 @@ /* Slider */ --pp-ui-slider-height: .25rem; --pp-ui-slider-height-interaction: .5rem; + --pp-ui-slider-range-bg-gold: #675f40; + --pp-ui-slider-range-progress-gold: #ffe206; --pp-ui-slider-range-bg: #b4b4b4; --pp-ui-slider-range-progress: #ffffff; --pp-ui-slider-range-thumb-bg: #ffffff; @@ -478,6 +480,14 @@ background-color: var(--pp-ui-slider-range-progress); } +.pp-ui-color-gold .pp-ui-slider-range { + background-color: var(--pp-ui-slider-range-bg-gold); +} + +.pp-ui-color-gold .pp-ui-slider-range-progress { + background-color: var(--pp-ui-slider-range-progress-gold); +} + .pp-ui-slider-range-thumb { height: 10px; width: 10px; @@ -514,8 +524,38 @@ .pp-ui-seekbar { position: relative; width: 100%; + height: 30px; +} + +.pp-ui-seekbar-layer { + position: absolute; + width: 100%; + height: 100%; } +.pp-ui-seekbar-layer.cues { + pointer-events: none; +} + +.pp-ui-seekbar-cues-margin { + margin: var(--pp-ui-slider-margin); + height: var(--pp-ui-slider-height); +} + +.pp-ui-seekbar-cues { + position: relative; + width: 100%; + height: 100%; +} + +.pp-ui-seekbar-cue { + background-color: rgba(7, 7, 7, 0.3); + height: 130%; + position: absolute; + top: 16px; +} + + .pp-ui-seekbar .pp-ui-thumbnail { display: none; position: absolute; @@ -573,6 +613,7 @@ bottom: 0; left: 0; right: 0; + z-index: 1; } :-webkit-full-screen .pp-ui-overlay.pp-ui-ipad { @@ -750,6 +791,10 @@ max-width: 200px; } +.pp-ui-basic-theme .pp-ui-top-bar { + height: 40px; +} + .pp-ui-spacer { flex-grow: 1; flex-shrink: 1; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7f8a707 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,19 @@ +/** + * A function that disposes of a resource. + */ +export type Disposer = () => void + +export type Cue = { + /** + * ID of the cue. + */ + id: string + /** + * End time of the cue in seconds. + */ + endTime: number + /** + * Start time of the cue in seconds. + */ + startTime: number +} diff --git a/src/utils.ts b/src/utils.ts index 1266835..880e3e9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,16 +35,22 @@ export function classNames(classes: Record, classNames?: string * * @param {number} timeInSeconds The duration in seconds * @param {string} [opt_format] The output format + * @param {number} [roundingMargin] this margin allows for smarter rounding. + * For example, if we use precision of seconds and the `timeInSeconds` value + * is 6.99, it would be rounded to 6. This can be ameliorated by setting a + * `roundingMargin` which is added to `timeInSeconds` before rounding in order + * to produce a more accurate result of 7. * @returns {string} The formatted duration as a string * @export */ -export function timeToString(timeInSeconds: number, opt_format = '%hh:%mm:%ss') { +export function timeToString(timeInSeconds: number, opt_format = '%hh:%mm:%ss', roundingMargin = 0.0) { if (timeInSeconds === null || timeInSeconds === undefined) { timeInSeconds = 0 } if (opt_format === null || opt_format === undefined) { opt_format = '%h:%mm:%ss' } + timeInSeconds = timeInSeconds + roundingMargin const hours = Math.floor(timeInSeconds / 3600) const minutes = Math.floor((timeInSeconds - (hours * 3600)) / 60) const seconds = Math.floor(timeInSeconds - (hours * 3600) - (minutes * 60)) diff --git a/story/stories/components/VuMeter.stories.tsx b/story/stories/components/VuMeter.stories.tsx index 761ec57..22609af 100644 --- a/story/stories/components/VuMeter.stories.tsx +++ b/story/stories/components/VuMeter.stories.tsx @@ -30,7 +30,6 @@ const Component = () => { }, []) const playerConfig = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment source: TEST_ASSETS[0].config.source ?? '', } diff --git a/story/stories/prep.tsx b/story/stories/prep.tsx index 04fb819..3ce945b 100644 --- a/story/stories/prep.tsx +++ b/story/stories/prep.tsx @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { clpp } from '@castlabs/prestoplay' import '@castlabs/prestoplay/cl.dash' import '@castlabs/prestoplay/cl.hls' diff --git a/tests/testUtils.tsx b/tests/testUtils.tsx index 759b2f4..e62ebd9 100644 --- a/tests/testUtils.tsx +++ b/tests/testUtils.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { render, act } from '@testing-library/react' import React from 'react' From 9d9410f0b60d49bdab2bac00c8dbff6e2b7c9f75 Mon Sep 17 00:00:00 2001 From: Artur Finger Date: Mon, 8 Apr 2024 11:11:20 +0300 Subject: [PATCH 2/2] DE-6967: HLS Interstitial Player Component --- app/src/App.tsx | 6 +- app/src/InterstitialPage.tsx | 62 ++++++ app/src/styles.css | 15 ++ package-lock.json | 18 +- package.json | 2 +- src/index.ts | 2 + src/interstitial/InterstitialPlayer.tsx | 172 ++++++++++++++++ src/interstitial/PlayerHlsi.ts | 183 ++++++++++++++++++ src/interstitial/components/Countdown.tsx | 43 ++++ src/interstitial/components/OverlayHlsi.tsx | 172 ++++++++++++++++ .../components/PlayerSurfaceHlsi.tsx | 118 +++++++++++ src/interstitial/hooks.ts | 35 ++++ src/interstitial/types.ts | 10 + src/themes/pp-ui-base-theme.css | 33 ++++ 14 files changed, 862 insertions(+), 9 deletions(-) create mode 100644 app/src/InterstitialPage.tsx create mode 100644 src/interstitial/InterstitialPlayer.tsx create mode 100644 src/interstitial/PlayerHlsi.ts create mode 100644 src/interstitial/components/Countdown.tsx create mode 100644 src/interstitial/components/OverlayHlsi.tsx create mode 100644 src/interstitial/components/PlayerSurfaceHlsi.tsx create mode 100644 src/interstitial/hooks.ts create mode 100644 src/interstitial/types.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index e13ff5b..139b5af 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import { Asset, TestAssets } from './Asset' import { BasicOverlayPage } from './BasicOverlayPage' import { ComponentsOverviewPage } from './ComponentsOverviewPage' import { CustomControlsPage } from './CustomControlsPage' +import { InterstitialPage } from './InterstitialPage' import { YoutubeControlsPage } from './YoutubeControlsPage' // load app styles @@ -17,7 +18,7 @@ import '@castlabs/prestoplay/clpp.styles.css' // load the theme import '../../src/themes/pp-ui-base-theme.css' -type Page = 'basic' | 'custom' | 'components' | 'youtube' +type Page = 'basic' | 'custom' | 'components' | 'youtube' | 'interstitial' function getQueryVariable(variable: string) { const searchParams = new URLSearchParams(window.location.search) @@ -60,6 +61,8 @@ export function App() { return } else if (pageId === 'youtube') { return + } else if (pageId === 'interstitial') { + return } return
Unknown Page!
}, [pageId, asset, autoload]) @@ -94,6 +97,7 @@ export function App() { +
diff --git a/app/src/InterstitialPage.tsx b/app/src/InterstitialPage.tsx new file mode 100644 index 0000000..1848f0e --- /dev/null +++ b/app/src/InterstitialPage.tsx @@ -0,0 +1,62 @@ +import { clpp } from '@castlabs/prestoplay' +import React, { useState } from 'react' + +import { InterstitialPlayer } from '../../src' + +/** + * A page featuring the HLS interstitial player. + */ +export const InterstitialPage = () => { + const [mounted, setMounted] = useState(true) + + const toggleMounted = () => { + setMounted(m => !m) + } + + return ( +
+
+
+ +
+ {mounted ? ( +
+ { + // // @ts-ignore + // window.player = p + // }} + // showInterstitialMarkers={false} + // seekStep={2} + // controlsVisibility='never' + // intermissionDuration={5} + // interstitialLabel={(i) => `Ad ${i.podOrder} of ${i.podCount}`} + // renderInterstitialLabel={(i) => null} + // renderIntermission={(seconds) =>
{seconds}
} + // loop={false} + // onEnded={() => {}} + // onLoopEnded={() => {}} + /> +
+ ): null} +
+
+ ) +} diff --git a/app/src/styles.css b/app/src/styles.css index 0955f0e..d3805c9 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -110,3 +110,18 @@ nav button:hover { background-color: #b4b4b4; } +.in-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.in-container { + width: 1000px; +} + +.in-video-container { + width: 800px; + height: 580px; +} diff --git a/package-lock.json b/package-lock.json index 576d565..a7fead5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@babel/preset-env": "^7.22.4", "@babel/preset-react": "^7.22.3", "@babel/preset-typescript": "^7.21.5", - "@castlabs/prestoplay": "^6.6.0", + "@castlabs/prestoplay": "^6.11.1-beta.1", "@finga/eslint-config": "^1.2.1", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-image": "^3.0.2", @@ -2347,12 +2347,13 @@ "license": "MIT" }, "node_modules/@castlabs/prestoplay": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@castlabs/prestoplay/-/prestoplay-6.6.0.tgz", - "integrity": "sha512-R6XjgzzKxtrwQoEUBNVFGSMmEZO0QgB0WmhBie59Y8RpcVUZg5FPsz1TPWwTdggu9QVj+cD2s6RB5SeiFNu7iQ==", + "version": "6.11.1-beta.1", + "resolved": "https://registry.npmjs.org/@castlabs/prestoplay/-/prestoplay-6.11.1-beta.1.tgz", + "integrity": "sha512-QUxzyxKaK1KEaC/fPKxFyn5BEqYHU5vXl/DngakgydHr3xHbjTCCL/e0uZ3f2RMyu8KmOiK18EjCjoFon+IhMQ==", "dev": true, "peerDependencies": { "@broadpeak/smartlib": "4.5.1-328cb1e", + "@broadpeak/smartlib-all-compatibility": "4.5.1-328cb1e", "@broadpeak/smartlib-analytics": "4.5.1-328cb1e", "mux.js": "^5.14.1", "youboralib": "6.8.49" @@ -2361,6 +2362,9 @@ "@broadpeak/smartlib": { "optional": true }, + "@broadpeak/smartlib-all-compatibility": { + "optional": true + }, "@broadpeak/smartlib-analytics": { "optional": true }, @@ -23312,9 +23316,9 @@ "dev": true }, "@castlabs/prestoplay": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@castlabs/prestoplay/-/prestoplay-6.6.0.tgz", - "integrity": "sha512-R6XjgzzKxtrwQoEUBNVFGSMmEZO0QgB0WmhBie59Y8RpcVUZg5FPsz1TPWwTdggu9QVj+cD2s6RB5SeiFNu7iQ==", + "version": "6.11.1-beta.1", + "resolved": "https://registry.npmjs.org/@castlabs/prestoplay/-/prestoplay-6.11.1-beta.1.tgz", + "integrity": "sha512-QUxzyxKaK1KEaC/fPKxFyn5BEqYHU5vXl/DngakgydHr3xHbjTCCL/e0uZ3f2RMyu8KmOiK18EjCjoFon+IhMQ==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index d900286..6b7ee89 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@babel/preset-env": "^7.22.4", "@babel/preset-react": "^7.22.3", "@babel/preset-typescript": "^7.21.5", - "@castlabs/prestoplay": "^6.6.0", + "@castlabs/prestoplay": "^6.11.1-beta.1", "@finga/eslint-config": "^1.2.1", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-image": "^3.0.2", diff --git a/src/index.ts b/src/index.ts index e49d26a..c2143fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,4 +40,6 @@ export * from './components/VolumeBar' export * from './components/PlayPauseIndicator' export * from './components/VuMeter' +export * from './interstitial/InterstitialPlayer' + export * from './context/PrestoContext' diff --git a/src/interstitial/InterstitialPlayer.tsx b/src/interstitial/InterstitialPlayer.tsx new file mode 100644 index 0000000..b366166 --- /dev/null +++ b/src/interstitial/InterstitialPlayer.tsx @@ -0,0 +1,172 @@ +import { clpp } from '@castlabs/prestoplay' +import '@castlabs/prestoplay/cl.mse' +import '@castlabs/prestoplay/cl.hls' +import React, { useEffect, useRef } from 'react' + +import { ControlsVisibilityMode } from '../services/controls' + +import { InterstitialOverlay } from './components/OverlayHlsi' +import { PlayerSurfaceHlsi } from './components/PlayerSurfaceHlsi' +import { PlayerHlsi } from './PlayerHlsi' +import { HlsInterstitial } from './types' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +clpp.install(clpp.hls.HlsComponent) + +export type InterstitialPlayerProps = { + /** + * HLS interstitial Asset + */ + asset: clpp.PlayerConfiguration + /** + * If the asset should be played back in a loop, one cycle of the loop + * consists of a few seconds of intermission and then the asset is played. + * Default: true. + */ + loop?: boolean + /** + * Intermission duration in seconds, defaults to 3. + */ + intermissionDuration?: number | null + /** + * Intermission element renderer. Default: Countdown + */ + renderIntermission?: (seconds: number) => (JSX.Element | null) + /** + * Callback called when playback ended (if loop is `false`) + */ + onEnded?: () => any + /** + * Callback called when one loop cycle ended (if loop is `true`) + */ + onLoopEnded?: () => any + /** + * Interstitial label text renderer. Default: `Interstitial ${podOrder} of ${podCount}` + */ + interstitialLabel?: (i: HlsInterstitial) => string + /** + * Interstitial label component renderer. + */ + renderInterstitialLabel?: (i: HlsInterstitial) => (JSX.Element | null) + /** + * Visibility mode of UI controls. Default: 'always-visible' + */ + controlsVisibility?: ControlsVisibilityMode + /** + * Seek step in seconds for seek buttons. A value of 0 will hide the buttons. + * Default: 10. + */ + seekStep?: number + /** + * If true interstitial markers should be shown on the timeline. Default: true. + */ + showInterstitialMarkers?: boolean + /** + * If true, a fullscreen button is displayed. Defaults to true. + */ + hasFullScreenButton?: boolean + /** + * If true, audio controls are displayed. Defaults to false. + */ + hasAudioControls?: boolean + /** + * If true, track controls are displayed. Defaults to false. + */ + hasTrackControls?: boolean + /** + * Callback called when the player of multi-controller changes. + */ + onPlayerChanged?: (p: clpp.Player) => any + /** + * Options for clpp.interstitial.Player + */ + interstitialOptions?: Omit + /** + * Custom class name for the player container. + */ + className?: string + /** + * Custom style for the player container. + */ + style?: React.CSSProperties + /** + * If true, the player will ignore all state changes to state "ended". + */ + patchIgnoreStateEnded?: boolean +} + +/** + * A dedicated component for playback of HLS streams with interstitials. + * + * By default the stream is played in an infinite loop with a countdown + * intermission in between. + */ +export const InterstitialPlayer = React.memo((props: InterstitialPlayerProps) => { + const playerRef = useRef(new PlayerHlsi()) + + useEffect(() => { + if (props.patchIgnoreStateEnded) { + playerRef.current.ignoreStateEnded = true + } + }, [props.patchIgnoreStateEnded]) + + const load = async () => { + try { + await playerRef.current.loadHlsi(props.asset) + } catch (e) { + console.error('Interstitial player Failed to load asset', e) + } + } + + useEffect(() => { + if (props.onPlayerChanged) { + playerRef.current.onUIEvent('playerChanged', props.onPlayerChanged) + } + + return () => { + if (props.onPlayerChanged) { + playerRef.current.offUIEvent('playerChanged', props.onPlayerChanged) + } + } + }, []) + + useEffect(() => { + return () => { + playerRef.current.destroy().catch(e => { + console.error('Failed to destroy Interstitial player', e) + }) + } + }, []) + + let className = 'pp-ui-hlsi-player' + if (props.className) { + className += ` ${props.className}` + } + + return ( +
+ + { + await load() + }} + onLoopEnded={async () => { + props.onLoopEnded?.() + await playerRef.current.reset() + await load() + }} + onIntermissionEnded={async () => { + await playerRef.current.unpause() + }} + /> + +
+ ) +}) + +InterstitialPlayer.displayName = 'InterstitialPlayer' diff --git a/src/interstitial/PlayerHlsi.ts b/src/interstitial/PlayerHlsi.ts new file mode 100644 index 0000000..79949bc --- /dev/null +++ b/src/interstitial/PlayerHlsi.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { clpp } from '@castlabs/prestoplay' + +import { EventListener, EventType } from '../EventEmitter' +import { Player, UIEvents } from '../Player' +import { Disposer } from '../types' + +import { HlsInterstitial } from './types' + + +export interface UIEventHlsi extends UIEvents { + /** + * Triggered when an HLS interstitial starts playing + */ + hlsInterstitial: (HlsInterstitial | null) + /** + * Triggered when primary content playback ended + */ + ended: undefined + /** + * Triggered when the player instance changes (e.g. when switching between + * primary and interstitial player(s)) + */ + playerChanged: clpp.Player +} + + +/** + * Player of HLS Interstitial streams + */ +export class PlayerHlsi extends Player { + /** + * The HLS interstitial player instance + */ + private _ip: clpp.interstitial.Player | null = null + private _options: clpp.interstitial.Options | null = null + private _interstitialCues: clpp.interstitial.UiCue[] = [] + /** + * True if interstitial asset content currently playing + * (as opposed to primary content) + */ + private _isPlayingInterstitial = false + private _disposers: Disposer[] = [] + + /** + * Initialize the HLS Interstitial player + */ + initHlsi(options: clpp.interstitial.Options) { + if (this._ip) {return} + + this.refreshCues_() + + this._options = options + this._ip = new clpp.interstitial.Player(options) + + this.on('interstitial-item-started', (event) => { + this.emitUIEvent('hlsInterstitial', { + podOrder: event.detail.podOrder as number, + podCount: event.detail.podCount as number, + }) + this._isPlayingInterstitial = true + }) + + this.on('interstitial-ended', () => { + this.emitUIEvent('hlsInterstitial', null) + this._isPlayingInterstitial = false + }) + + this.on('cues-changed', () => { + this.refreshCues_() + }) + + this.on('item-changed', () => { + const item = this._ip?.getCurrentItem() ?? null + if (!item || !item.player) { + console.error('Interstitial: item-changed event without item/player') + return + } + + const player = item.player + this.pp_ = player + + this.removePrestoListeners_() + this.refreshPrestoState_(player) + this.attachPrestoListeners_(player) + this.emitUIEvent('playerChanged', player) + }) + + this.on('primary-ended', () => { + this.emitUIEvent('ended', undefined) + }) + } + + /** + * Load an asset paused. + */ + async loadHlsi(config?: clpp.PlayerConfiguration) { + if (!this._ip || !config) {return} + await this._ip.loadPaused(config) + } + + /** + * Unpause the currently loaded (paused) asset. + */ + async unpause() { + if (!this._ip) {return} + await this._ip.unpause() + } + + /** + * Reset this player to a completely fresh state (same as newly constructed). + */ + async reset() { + await this._ip?.destroy() + this._ip = null + await this.release() + if (this._options) { + this.initHlsi(this._options) + } + } + + /** + * Destroy the player + */ + async destroy() { + this._disposers.forEach((dispose) => dispose()) + this._disposers = [] + await this._ip?.destroy() + this._ip = null + await this.release() + } + + /** + * Get HLS Interstitial cues. + */ + getInterstitialCues() { + return this._interstitialCues + } + + emitUIEvent>(type: K, data: UIEventHlsi[K]): void { + super.emitUIEvent(type as any, data as any) + } + + offUIEvent>(type: K, listener: EventListener): void { + super.offUIEvent(type as any, listener as any) + } + + onUIEvent>(type: K, listener: EventListener): void { + super.onUIEvent(type as any, listener as any) + } + + private refreshCues_ () { + if (this._isPlayingInterstitial) { + this.setCues([]) + return + } + + this._interstitialCues = (this._ip?.getCues() ?? []) + const cues = this._interstitialCues.map((cue) => { + return { + id: cue.cueId, + startTime: cue.startTime, + endTime: cue.endTime, + } + }) + .filter(cue => cue.startTime > 2) // remove interstitial preroll cue(s) + this.setCues(cues) + } + + /** + * Attach an event listener to the interstitial player + */ + private on(event: string, listener: EventListener) { + if (!this._ip) {return} + // @ts-ignore + this._ip.on(event, listener) + this._disposers.push(() => { + // @ts-ignore + this._ip.off(event, listener) + }) + } +} diff --git a/src/interstitial/components/Countdown.tsx b/src/interstitial/components/Countdown.tsx new file mode 100644 index 0000000..6a7a330 --- /dev/null +++ b/src/interstitial/components/Countdown.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useRef, useState } from 'react' + +type Props = { + seconds: number + render?: (seconds: number) =>(JSX.Element | null) + onDone?: () => any +} + +/** + * Countdown component + */ +const CountDown = React.memo((props: Props) => { + const [count, setCount] = useState(props.seconds) + const countRef = useRef(count) + + useEffect(() => { + const interval = setInterval(() => { + countRef.current -= 1 + if (countRef.current === 0) { + clearInterval(interval) + props.onDone?.() + } else { + setCount((c) => c - 1) + } + }, 1000) + + return () => clearInterval(interval) + }, []) + + if (props.render) { + return props.render(count) + } + + return ( +
+
{count}
+
+ ) +}) + +CountDown.displayName = 'CountDown' + +export { CountDown } diff --git a/src/interstitial/components/OverlayHlsi.tsx b/src/interstitial/components/OverlayHlsi.tsx new file mode 100644 index 0000000..ae2322e --- /dev/null +++ b/src/interstitial/components/OverlayHlsi.tsx @@ -0,0 +1,172 @@ + +import React, { useCallback, useState } from 'react' + +import { BaseThemeOverlay } from '../../components/BaseThemeOverlay' +import { HorizontalBar } from '../../components/HorizontalBar' +import { StartButton } from '../../components/StartButton' +import { ControlsVisibilityMode } from '../../services/controls' +import { useHlsInterstitial, usePrestoUiEventHlsi } from '../hooks' +import { HlsInterstitial } from '../types' + +import { CountDown } from './Countdown' + + +export type Props = { + /** + * If the asset should be played back in a loop, one cycle of the loop + * consists of a few seconds of intermission and then the asset is played. + * Default: true. + */ + loop?: boolean + /** + * Intermission duration in seconds, defaults to 3. + */ + intermissionDuration?: number | null + /** + * Intermission element renderer. Default: Countdown + */ + renderIntermission?: (seconds: number) => (JSX.Element | null) + /** + * Callback called when playback ended (if loop is `false`) + */ + onEnded?: () => any + /** + * Callback called when one loop cycle ended (if loop is `true`) + */ + onLoopEnded?: () => any + /** + * Callback called when the intermission ended. + */ + onIntermissionEnded?: () => any + /** + * Callback called when start button was clicked + */ + onStartClick?: () => any + /** + * Interstitial label text renderer. Default: `Interstitial ${podOrder} of ${podCount}` + */ + interstitialLabel?: (i: HlsInterstitial) => string + /** + * Interstitial label component renderer. + */ + renderInterstitialLabel?: (i: HlsInterstitial) => (JSX.Element | null) + /** + * Visibility mode of UI controls. Default: 'always-visible' + */ + controlsVisibility?: ControlsVisibilityMode + /** + * Seek step in seconds for seek buttons. A value of 0 will hide the buttons. + * Default: 10. + */ + seekStep?: number + /** + * If true interstitial markers should be shown on the timeline. Default: true. + */ + showInterstitialMarkers?: boolean + /** + * If true, a fullscreen button is displayed. Defaults to true. + */ + hasFullScreenButton?: boolean + /** + * If true, audio controls are displayed. Defaults to false. + */ + hasAudioControls?: boolean + /** + * If true, track controls are displayed. Defaults to false. + */ + hasTrackControls?: boolean + /** + * Custom class name for the player container. + */ + className?: string + /** + * Custom style for the player container. + */ + style?: React.CSSProperties +} + +/** + * UI overlay for HLS interstitial player. + */ +export const InterstitialOverlay = React.memo((props: Props) => { + const [hadInteraction, setHadInteraction] = useState(false) + const [intermission, setIntermission] = useState(true) + const interstitial = useHlsInterstitial() + const loop = props.loop ?? true + const seekStep = props.seekStep ?? 10 + + const endIntermission = useCallback(() => { + setIntermission(false) + props.onIntermissionEnded?.() + }, [props.onIntermissionEnded]) + + const onStartClick = useCallback(() => { + setHadInteraction(true) + props.onStartClick?.() + }, [props.onStartClick]) + + usePrestoUiEventHlsi('ended', () => { + if (loop) { + props.onLoopEnded?.() + setIntermission(true) + } else { + props.onEnded?.() + } + }) + + /** + * Render info about the currently playing HLS interstitial if there is one. + */ + const renderInterstitialInfo = () => { + if (!interstitial) { + return null + } + + let content = null + if (props.renderInterstitialLabel) { + content = props.renderInterstitialLabel(interstitial) + } else { + let text = null + if (props.interstitialLabel) { + text = props.interstitialLabel(interstitial) + } else { + text = `Interstitial ${interstitial.podOrder} of ${interstitial.podCount}` + } + content =
{text}
+ } + + return ( + + {content} + + ) + } + + if (!hadInteraction) { + return + } + + if (intermission) { + const duration = props.intermissionDuration ?? 3 + return + } + + return +}) + +InterstitialOverlay.displayName = 'InterstitialOverlay' diff --git a/src/interstitial/components/PlayerSurfaceHlsi.tsx b/src/interstitial/components/PlayerSurfaceHlsi.tsx new file mode 100644 index 0000000..07a5ca5 --- /dev/null +++ b/src/interstitial/components/PlayerSurfaceHlsi.tsx @@ -0,0 +1,118 @@ +import { clpp } from '@castlabs/prestoplay' +import React, { useCallback, useEffect, useRef, useState } from 'react' + +import { BaseComponentProps } from '../../components/types' +import { PrestoContext, PrestoContextType } from '../../context/PrestoContext' +import { PlayerHlsi } from '../PlayerHlsi' + +export interface PlayerProps extends BaseComponentProps { + /** + * The player instance + */ + player: PlayerHlsi + children?: React.ReactNode + /** + * Options of HLS Interstitial Player + */ + interstitialOptions?: Omit +} + +const getContext = (nullableContext: Partial) => { + if (!nullableContext.playerSurface || !nullableContext.player) { + return null + } + + return nullableContext as PrestoContextType +} + +/** + * Player Surface for HLS Interstitial Player + */ +export const PlayerSurfaceHlsi = (props: PlayerProps) => { + const [nullableContext, setPrestoContext ] = useState>({ + playerSurface: undefined, + player: props.player, + presto: undefined, + }) + const containerRef = useRef(null) + + const onAnchorRef = useCallback((anchor: HTMLDivElement) => { + if (!anchor) {return} + props.player.initHlsi({ ...(props.interstitialOptions ?? {}), anchorEl: anchor }) + }, []) + + useEffect(() => { + const setPresto = (player: clpp.Player) => { + setPrestoContext(context => ({ + ...context, + presto: player, + })) + } + props.player.onUIEvent('playerChanged', setPresto) + + return () => { + props.player.offUIEvent('playerChanged', setPresto) + props.player.release() + .catch(err => console.error('Failed to release the player', err)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const mouseMove = () => { + if (!props.player.controlsVisible && !props.player.slideInMenuVisible) { + props.player.surfaceInteraction() + } + } + + const mouseClick = (e: React.MouseEvent) => { + if (!e.defaultPrevented) { + if (props.player.slideInMenuVisible) { + props.player.slideInMenuVisible = false + e.preventDefault() + } else if (props.player.controlsVisible) { + props.player.controlsVisible = false + e.preventDefault() + } else { + props.player.surfaceInteraction() + e.preventDefault() + } + } + } + + const handleContainerRef = (element: HTMLDivElement|null) => { + containerRef.current = element + + if (element && !nullableContext.playerSurface) { + setPrestoContext(context => ({ + ...context, + playerSurface: element, + })) + } + } + + const context = getContext(nullableContext) + + return ( +
+
+ {context && + +
+ {props.children} +
+
+ } +
+ ) +} diff --git a/src/interstitial/hooks.ts b/src/interstitial/hooks.ts new file mode 100644 index 0000000..d871c52 --- /dev/null +++ b/src/interstitial/hooks.ts @@ -0,0 +1,35 @@ +import { useContext, useEffect, useState } from 'react' + +import { PrestoContext } from '../context/PrestoContext' +import { EventListener, EventType } from '../EventEmitter' + +import { PlayerHlsi, UIEventHlsi } from './PlayerHlsi' +import { HlsInterstitial } from './types' + +/** + * Helper hook to listen to UI related events from the player + */ +export function usePrestoUiEventHlsi>( + eventName: E, handler: EventListener, dependencies?: unknown[], +) { + const player = useContext(PrestoContext).player as PlayerHlsi + dependencies = dependencies || [] + + useEffect(() => { + player.onUIEvent(eventName, handler) + return () => { + player.offUIEvent(eventName, handler) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventName, ...dependencies]) +} + + +/** + * @returns Metadata about the currently playing HLS interstitial. + */ +export const useHlsInterstitial = () => { + const [interstitial, setInterstitial] = useState(null) + usePrestoUiEventHlsi('hlsInterstitial', setInterstitial) + return interstitial +} diff --git a/src/interstitial/types.ts b/src/interstitial/types.ts new file mode 100644 index 0000000..931cfbe --- /dev/null +++ b/src/interstitial/types.ts @@ -0,0 +1,10 @@ +export type HlsInterstitial = { + /** + * Ordering number of a HLS interstitial asset inside its pod. (starts from 1) + */ + podOrder: number + /** + * Number of assets in one HLS interstitial (pod). + */ + podCount: number +} diff --git a/src/themes/pp-ui-base-theme.css b/src/themes/pp-ui-base-theme.css index 716adde..725ac08 100644 --- a/src/themes/pp-ui-base-theme.css +++ b/src/themes/pp-ui-base-theme.css @@ -856,3 +856,36 @@ display: none; } } + + +/** Styles for interstitial HLS player */ +.pp-ui-hlsi-video-anchor { + width: 100%; + height: 100%; +} + +.pp-ui-hlsi-companion-label { + margin-left: 18px; + margin-bottom: 8px; + color: white; + text-shadow: black 1px 1px 5px; +} + +.pp-ui-hlsi-player { + width: 100%; + height: 100%; +} + +.pp-ui-countdown { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + background: black; +} + +.pp-ui-countdown-count { + font-size: 200px; +} +/** END: Styles for interstitial HLS player */