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'