From 2a320b9483ec33eab32c284224a2f10c4bdb642a Mon Sep 17 00:00:00 2001 From: Artur Finger Date: Mon, 8 Apr 2024 11:11:20 +0300 Subject: [PATCH] DE-6967: HLS Interstitial Player Component --- app/src/App.tsx | 6 +- app/src/InterstitialPage.tsx | 46 +++++ app/src/styles.css | 15 ++ src/index.ts | 2 + src/interstitial/InterstitialPlayer.tsx | 159 ++++++++++++++++ src/interstitial/PlayerHlsi.ts | 163 +++++++++++++++++ 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 ++++ 12 files changed, 801 insertions(+), 1 deletion(-) 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..151e6a2 --- /dev/null +++ b/app/src/InterstitialPage.tsx @@ -0,0 +1,46 @@ +import { clpp } from '@castlabs/prestoplay' +import React from 'react' + +import { InterstitialPlayer } from '../../src' + +/** + * A page featuring the HLS interstitial player. + */ +export const InterstitialPage = () => { + return ( +
+
+
+ { + // // @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={() => {}} + /> +
+
+
+ ) +} 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/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..e5f2f5c --- /dev/null +++ b/src/interstitial/InterstitialPlayer.tsx @@ -0,0 +1,159 @@ +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 +} + +/** + * Interstitial player component. + */ +export const InterstitialPlayer = React.memo((props: InterstitialPlayerProps) => { + const playerRef = useRef(new PlayerHlsi()) + + useEffect(() => { + // Possibly it's something wrong with the AIP stream http://localhost:3000/vod-preroll.m3u8 + // but unfortunately what happens is that we get state "Ended" and then the video + // continues playing for another cca 800ms. This would obviously cause a glitch + // in the UI so configure the player to ignore all ended states changes + playerRef.current.ignoreStateEnded = true + }, []) + + 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) + } + } + }, []) + + 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..9d49872 --- /dev/null +++ b/src/interstitial/PlayerHlsi.ts @@ -0,0 +1,163 @@ +/* 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 { 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 with HLS Interstitial support + */ +export class PlayerHlsi extends Player { + /** + * The interstitial player instance + */ + private ip_: clpp.interstitial.Player | null = null + private _options: clpp.interstitial.Options | null = null + private _interstitialCues: clpp.interstitial.UiCue[] = [] + private _isPlayingInterstitial = false + + initHlsi(options: clpp.interstitial.Options) { + if (this.ip_) {return} + + this.refreshCues_() + + this._options = options + this.ip_ = new clpp.interstitial.Player(options) + + // @ts-ignore + this.ip_.on('interstitial-item-started', (event) => { + this.emitUIEvent('hlsInterstitial', { + podOrder: event.detail.podOrder as number, + podCount: event.detail.podCount as number, + }) + this._isPlayingInterstitial = true + }) + + // @ts-ignore + this.ip_.on('interstitial-ended', () => { + this.emitUIEvent('hlsInterstitial', null) + this._isPlayingInterstitial = false + }) + + // @ts-ignore + this.ip_.on('cues-changed', () => { + this.refreshCues_() + }) + + // this.ip_.on('interstitial-item-started', (event) => { + // console.info('Interstitial event interstitial-item-started', event) + // }) + + // this.ip_.on('primary-player-changed', (event) => { + // console.info('Primary player changed', event) + // }) + + // @ts-ignore + this.ip_.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) + }) + + // @ts-ignore + this.ip_.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) + } + } + + /** + * 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) + } +} 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 */