From c460a15aed7e2a8933c4185dba05e6381a4967e2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 7 Sep 2024 13:54:15 +0200 Subject: [PATCH] feat: Tooltip in UI * Basic tooltip * Color changed * Added forward button * Updated docs --- packages/docs/features/stitcher.md | 90 +++++++++++ packages/hlsjs/README.md | 2 + packages/hlsjs/lib/main.ts | 80 +++++++--- .../hlsjs/lib/ui/components/Controls.scss | 5 + packages/hlsjs/lib/ui/components/Controls.tsx | 37 ++++- .../hlsjs/lib/ui/components/Progress.scss | 98 ++++++------ packages/hlsjs/lib/ui/components/Progress.tsx | 140 ++++++++++++------ packages/hlsjs/lib/ui/components/Root.tsx | 4 + packages/hlsjs/lib/ui/components/TimeStat.tsx | 44 +----- packages/hlsjs/lib/ui/hooks/useTime.ts | 21 +++ packages/hlsjs/lib/ui/icons/forward.svg | 6 + packages/hlsjs/lib/ui/index.scss | 7 + packages/hlsjs/lib/ui/utils.ts | 24 +++ packages/hlsjs/src/main.tsx | 4 +- 14 files changed, 398 insertions(+), 164 deletions(-) create mode 100644 packages/hlsjs/lib/ui/hooks/useTime.ts create mode 100644 packages/hlsjs/lib/ui/icons/forward.svg create mode 100644 packages/hlsjs/lib/ui/utils.ts diff --git a/packages/docs/features/stitcher.md b/packages/docs/features/stitcher.md index 47efbbea..15ec3654 100644 --- a/packages/docs/features/stitcher.md +++ b/packages/docs/features/stitcher.md @@ -14,3 +14,93 @@ Stitcher is a playlist manipulator that can insert HLS interstitials on-the-fly. ::: info Stitcher is in alpha, until we have proper documentation for this, refer to the source for more info and the API contract. Get in touch if you have questions! ::: + +## Create a session + +Each playout to a viewer can be considered a session. + +```sh [shell] +curl -X POST https://stitcher.domain.com/session + -H "Content-Type: application/json" + -d "{body}" +``` + +A minimal body payload may look like this: + +```json +{ + "assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689" +} +``` + +Behind the scenes, stitcher will create a session and return you a personalised playlist url. Each session is identifiable by a randomly generated uuid. In the example below, we got back a new session with id `44220f14-ffdd-4cfa-a67f-62ef421b4460`. As all we did was create a session with an `assetId`, the resulting master playlist will only cover that asset. Scroll further down if you'd like to extend the session with ads or a bumper. + +```json +{ + "url": "https://stitcher.domain.com/session/44220f14-ffdd-4cfa-a67f-62ef421b4460/master.m3u8" +} +``` + +## Playlist manipulation + +### Limit resolution + +```json +{ + "assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689", + "maxResolution": 480 +} +``` + +## Interstitials + +### Manual + +Let's say you transcoded and packaged a new asset with the id `abbda878-8e08-40f6-ac8b-3507f263450a`. The example below will add it as an interstitial. An HLS interstitials supported player will then switch to the new asset at position `10` and when finished, it'll go back to the main master playlist. + +```json +{ + "assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689", + "interstitials": [ + { + "timeOffset": 10, + "assetId": "abbda878-8e08-40f6-ac8b-3507f263450a" + } + ] +} +``` + +### Bumper + +You can manually add a bumper at timeOffset 0 but it is advised to use the `bumperAssetId` option instead. + +```json +{ + "assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689", + "bumperAssetId": "abbda878-8e08-40f6-ac8b-3507f263450a" +} +``` + +::: info +When you use both vmapUrl and bumperAssetId, it'll add the bumper interstitial as the last asset in a preroll (starting from 0). +::: + +### VMAP + +Instruct stitcher to add interstitials based on VMAP definitions. Each VMAP contains one or more `AdBreak` elements with a position of where the interstitial should be. + +```json +{ + "assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689", + "vmapUrl": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpost&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=" +} +``` + +1. Stitcher will fetch the VMAP. Parses, resolves and flattens each corresponding VAST. +2. For each ad that has not yet been transcoded, it'll start a transcode and package job with sane defaults. + - Each transcode or package job responsible for an ad is tagged with `ad` and can be observed in the dashboard. +3. For each ad that is available, it'll add an interstitial for playback. + +::: warning +Ad impressions are not tracked yet, we'd eventually like to provide a client wrapper that tracks ads in a certified manner. +::: diff --git a/packages/hlsjs/README.md b/packages/hlsjs/README.md index 2962f64a..6197dc17 100644 --- a/packages/hlsjs/README.md +++ b/packages/hlsjs/README.md @@ -9,3 +9,5 @@ Using https://www.svgrepo.com/collection/solar-broken-line-icons for icons. - When interstitials startTime is beyond the end of the content duration, there are stalls sometimes and content stops buffering. This can be replicated manually when setting interstitials further away. - Selecting audio and quality during interstitials works for the main asset, but subtitles not. + +- Seeking to `this.getInterstitialsManager_().integrated.duration` does not work properly. diff --git a/packages/hlsjs/lib/main.ts b/packages/hlsjs/lib/main.ts index a9a29e88..db1baa7b 100644 --- a/packages/hlsjs/lib/main.ts +++ b/packages/hlsjs/lib/main.ts @@ -28,15 +28,10 @@ export type HlsAudioTrack = { playlist: MediaPlaylist; }; -export type HlsSeekRange = { - start: number; - end: number; -}; - export type HlsState = { - playheadState: "idle" | "play" | "pause"; + playheadState: "idle" | "play" | "pause" | "ended"; time: number; - seekRange: HlsSeekRange; + duration: number; interstitial: HlsInterstitial | null; cuePoints: number[]; qualities: HlsQuality[]; @@ -49,13 +44,44 @@ export type HlsFacadeEvent = { "*": () => void; }; +const defaultState: HlsState = { + playheadState: "idle", + time: 0, + duration: 0, + interstitial: null, + cuePoints: [], + qualities: [], + autoQuality: false, + subtitleTracks: [], + audioTracks: [], +}; + export class HlsFacade extends EventEmitter { private intervalId_: number | undefined; constructor(public hls: Hls) { super(); + const { media } = hls; + if (!media) { + throw new HlsFacadeNoMedia(); + } + + hls.on(Hls.Events.BUFFER_RESET, () => { + media.removeAttribute("autoplay"); + this.setState_({ $set: defaultState }); + }); + + media.addEventListener("play", () => this.syncTimings_()); + + media.addEventListener("seeked", () => this.syncTimings_()); + + media.addEventListener("playing", () => this.syncTimings_(true)); + hls.once(Hls.Events.BUFFER_CREATED, () => { + this.setState_({ + autoQuality: { $set: this.hls.autoLevelEnabled }, + }); this.syncTimings_(); }); @@ -108,23 +134,22 @@ export class HlsFacade extends EventEmitter { }, }); }); + + hls.on(Hls.Events.MEDIA_ENDED, () => { + this.syncTimings_(false); + + this.setState_({ + playheadState: { $set: "ended" }, + time: { $set: this.state.duration }, + }); + }); } destroy() { clearInterval(this.intervalId_); } - state: HlsState = { - playheadState: "idle", - time: 0, - seekRange: { start: 0, end: 0 }, - interstitial: null, - cuePoints: [], - qualities: [], - autoQuality: this.hls.autoLevelEnabled, - subtitleTracks: [], - audioTracks: [], - }; + state: HlsState = defaultState; playOrPause() { const media = this.getMedia_(); @@ -168,8 +193,8 @@ export class HlsFacade extends EventEmitter { const nextState = update(this.state, spec); if (nextState !== this.state) { this.state = nextState; + this.emit("*"); } - this.emit("*"); } private getInterstitialsManager_() { @@ -189,20 +214,23 @@ export class HlsFacade extends EventEmitter { return media; } - private syncTimings_() { + private syncTimings_(repeat?: boolean) { clearInterval(this.intervalId_); const onTick = () => { const { integrated } = this.getInterstitialsManager_(); this.setState_({ - time: { $set: integrated.currentTime }, - seekRange: { $merge: { start: 0, end: integrated.duration } }, + time: { $set: precise(integrated.currentTime) }, + duration: { $set: precise(integrated.duration) }, }); }; - this.intervalId_ = setInterval(onTick, 500); - onTick(); + if (repeat === undefined) { + onTick(); + } else if (repeat === true) { + this.intervalId_ = setInterval(onTick, 500); + } } private syncQualities_() { @@ -259,3 +287,7 @@ export class HlsFacadeNoInterstitialsManager extends Error { super("No interstitials manager found"); } } + +function precise(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} diff --git a/packages/hlsjs/lib/ui/components/Controls.scss b/packages/hlsjs/lib/ui/components/Controls.scss index 539a33bf..96d87f90 100644 --- a/packages/hlsjs/lib/ui/components/Controls.scss +++ b/packages/hlsjs/lib/ui/components/Controls.scss @@ -38,3 +38,8 @@ .mix-controls-progress { display: flex; } + +.mix-controls-progress-container { + flex-grow: 1; + margin: 0 1.5em; +} diff --git a/packages/hlsjs/lib/ui/components/Controls.tsx b/packages/hlsjs/lib/ui/components/Controls.tsx index f27b9f82..a804f6f2 100644 --- a/packages/hlsjs/lib/ui/components/Controls.tsx +++ b/packages/hlsjs/lib/ui/components/Controls.tsx @@ -4,11 +4,13 @@ import PlayIcon from "../icons/play.svg?react"; import PauseIcon from "../icons/pause.svg?react"; import SettingsIcon from "../icons/settings.svg?react"; import SubtitlesIcon from "../icons/subtitles.svg?react"; +import ForwardIcon from "../icons/forward.svg?react"; import { useVisible } from "../hooks/useVisible"; import { Settings } from "./Settings"; import { SqButton } from "./SqButton"; import { useSettings } from "../hooks/useSettings"; import { TimeStat } from "./TimeStat"; +import { useTime } from "../hooks/useTime"; import type { HlsState, HlsFacade } from "../../main"; type ControlsProps = { @@ -29,6 +31,8 @@ export function Controls({ facade, state }: ControlsProps) { controlsVisible = true; } + const [time, setTargetTime] = useTime(state.time); + return ( <>
{showSeekbar(state) ? (
- { - facade.seekTo(time); - }} - /> - +
+ { + setTargetTime(time); + facade.seekTo(time); + }} + /> +
+
) : null}
- facade.playOrPause()}> + { + facade.playOrPause(); + nudge(); + }} + > {state.playheadState === "play" ? : } + { + facade.seekTo(time + 10); + nudge(); + }} + > + +
setSettingsMode("text-audio")} diff --git a/packages/hlsjs/lib/ui/components/Progress.scss b/packages/hlsjs/lib/ui/components/Progress.scss index f6cab7fb..6bd45977 100644 --- a/packages/hlsjs/lib/ui/components/Progress.scss +++ b/packages/hlsjs/lib/ui/components/Progress.scss @@ -1,59 +1,22 @@ .mix-progress { position: relative; height: 3em; - margin: 0 1.5em; - width: 100%; -} - -.mix-progress-range { - position: relative; - width: 100%; - height: 100%; + display: flex; + align-items: center; cursor: pointer; - z-index: 1; - - appearance: none; - background: transparent; - margin: 0; - - &::-webkit-slider-runnable-track { - height: 0; - } - - &::-webkit-slider-thumb { - z-index: 100; - appearance: none; - background-color: #ffffff; - height: 1.25em; - width: 1.25em; - transform: translateY(-50%); - border-radius: 100%; - transition: all 250ms ease-in-out; - } - - &:active::-webkit-slider-thumb { - box-shadow: - 0 1px 2px rgba(255, 255, 255, 0.5), - 0 0 0 6px rgba(255, 255, 255, 0.25); - } } -.mix-progress-bg { - height: 0.25em; - background-color: rgba(255, 255, 255, 0.5); - top: 50%; - transform: translateY(-50%); +.mix-progress-value { position: absolute; left: 0; - right: 0; + background-color: #ffffff; + height: 0.25em; } -.mix-progress-value { +.mix-progress-value-hover { position: absolute; left: 0; - transform: translateY(-50%); - background-color: #ffffff; - top: 50%; + background-color: rgba(255, 255, 255, 0.5); height: 0.25em; } @@ -62,8 +25,51 @@ width: 0.75em; height: 0.75em; background-color: #ffd32c; - top: 50%; - transform: translate(-50%, -50%); border-radius: 100%; border: 0.125em solid #000000; + transform: translateX(-50%); +} + +.mix-progress-tooltip { + position: absolute; + background-color: rgba(0, 0, 0, 0.95); + color: #ffffff; + border-radius: 0.25em; + transform: translateX(-50%); + bottom: 3em; + opacity: 0; + transition: opacity 150ms ease; + height: 2em; + padding: 0 1em; + display: flex; + align-items: center; + + &.mix-progress-tooltip--active { + opacity: 1; + } +} + +.mix-progress-bg { + height: 0.25em; + background-color: rgba(255, 255, 255, 0.5); + position: absolute; + left: 0; + width: 100%; +} + +.mix-progress-scrubber { + position: absolute; + background-color: #ffffff; + height: 1.25em; + width: 1.25em; + border-radius: 100%; + transform: translateX(-50%); + transition: box-shadow 250ms ease-in-out; + z-index: 10; + + &.mix-progress-scrubber--active { + box-shadow: + 0 1px 2px rgba(255, 255, 255, 0.5), + 0 0 0 6px rgba(255, 255, 255, 0.25); + } } diff --git a/packages/hlsjs/lib/ui/components/Progress.tsx b/packages/hlsjs/lib/ui/components/Progress.tsx index f8a85f0a..7bd9b365 100644 --- a/packages/hlsjs/lib/ui/components/Progress.tsx +++ b/packages/hlsjs/lib/ui/components/Progress.tsx @@ -1,85 +1,131 @@ -import { PointerEventHandler, useRef, useState } from "react"; -import { useDelta } from "../hooks/useDelta"; -import type { ChangeEventHandler } from "react"; +import { + PointerEventHandler, + useRef, + useState, + useEffect, + useCallback, +} from "react"; +import { toHMS } from "../utils"; +import cn from "clsx"; import type { HlsState } from "../../main"; type ProgressProps = { + time: number; state: HlsState; onSeeked(value: number): void; }; -export function Progress({ state, onSeeked }: ProgressProps) { +export function Progress({ time, state, onSeeked }: ProgressProps) { + const ref = useRef(null); const [seeking, setSeeking] = useState(false); - const [value, setValue] = useState(state.time); + const [hover, setHover] = useState(false); + const [value, setValue] = useState(0); - const deltaTime = useDelta(state.time); + const updateValue = useCallback( + (event: PointerEvent | React.PointerEvent) => { + let x = + (event.pageX - ref.current!.offsetLeft) / ref.current!.offsetWidth; + x = Math.min(Math.max(x, 0), 1); + x *= state.duration; - const lastSeekRef = useRef(null); + setValue(x); - const onChange: ChangeEventHandler = (event) => { - setValue(event.target.valueAsNumber); - }; + return x; + }, + [state.duration], + ); - const onPointerDown: PointerEventHandler = (event) => { + const onPointerDown: PointerEventHandler = (event) => { + event.preventDefault(); + updateValue(event); setSeeking(true); - setValue(event.currentTarget.valueAsNumber); }; - const onPointerUp: PointerEventHandler = (event) => { - const targetTime = event.currentTarget.valueAsNumber; - - lastSeekRef.current = targetTime; + const onPointerEnter = () => { + setHover(true); + }; - setSeeking(false); - onSeeked(targetTime); + const onPointerLeave = () => { + setHover(false); }; - let time = state.time; + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + updateValue(event); + }; + + window.addEventListener("pointermove", onPointerMove); - if (lastSeekRef.current !== null) { - // When we have a positive delta, thus we're increasing in time - // and the time is larger than lastSeek, we no longer need to overwrite - // the value. - if (deltaTime && time > lastSeekRef.current) { - lastSeekRef.current = null; - } else { - time = lastSeekRef.current; + return () => { + window.removeEventListener("pointermove", onPointerMove); + }; + }, [updateValue]); + + useEffect(() => { + if (!seeking) { + return; } - } - const progress = seeking ? value : time; + const onPointerUp = (event: PointerEvent) => { + const time = updateValue(event); + onSeeked(time); + setSeeking(false); + }; - const duration = state.seekRange.end - state.seekRange.start; + window.addEventListener("pointerup", onPointerUp); - if (!duration) { - return null; - } + return () => { + window.removeEventListener("pointerup", onPointerUp); + }; + }, [updateValue, seeking]); + + const active = seeking || hover; + const progress = seeking ? value : time; return ( -
- +
+
+ {toHMS(value)} +
+ {active ? ( +
+ ) : null}
+
{state.cuePoints.map((cuePoint) => (
))}
diff --git a/packages/hlsjs/lib/ui/components/Root.tsx b/packages/hlsjs/lib/ui/components/Root.tsx index 663d8a3d..d56391f6 100644 --- a/packages/hlsjs/lib/ui/components/Root.tsx +++ b/packages/hlsjs/lib/ui/components/Root.tsx @@ -9,5 +9,9 @@ type RootProps = { export function Root({ facade }: RootProps) { const state = useHlsState(facade); + if (!state.duration) { + return null; + } + return ; } diff --git a/packages/hlsjs/lib/ui/components/TimeStat.tsx b/packages/hlsjs/lib/ui/components/TimeStat.tsx index 71811bf2..cdfce282 100644 --- a/packages/hlsjs/lib/ui/components/TimeStat.tsx +++ b/packages/hlsjs/lib/ui/components/TimeStat.tsx @@ -1,45 +1,13 @@ +import { toHMS } from "../utils"; import type { HlsState } from "../../main"; type TimeStatProps = { + time: number; state: HlsState; }; -export function TimeStat({ state }: TimeStatProps) { - const remainingTime = - state.seekRange.end - state.time - state.seekRange.start; - - const value = toHMS(remainingTime); - - return value !== null ? ( -
{toHMS(remainingTime)}
- ) : null; -} - -export function toHMS(seconds: number) { - if (!Number.isFinite(seconds)) { - return null; - } - - const pad = (value: number) => - (10 ** 2 + Math.floor(value)).toString().substring(1); - - seconds = Math.floor(seconds); - if (seconds < 0) { - seconds = 0; - } - - let result = ""; - - const h = Math.trunc(seconds / 3600) % 24; - if (h) { - result += `${pad(h)}:`; - } - - const m = Math.trunc(seconds / 60) % 60; - result += `${pad(m)}:`; - - const s = Math.trunc(seconds % 60); - result += `${pad(s)}`; - - return result; +export function TimeStat({ time, state }: TimeStatProps) { + const remaining = Math.ceil(state.duration - time); + const hms = toHMS(remaining); + return
{hms}
; } diff --git a/packages/hlsjs/lib/ui/hooks/useTime.ts b/packages/hlsjs/lib/ui/hooks/useTime.ts new file mode 100644 index 00000000..21062925 --- /dev/null +++ b/packages/hlsjs/lib/ui/hooks/useTime.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { useDelta } from "./useDelta"; + +export function useTime(time: number) { + const [targetTime, setTargetTime] = useState(null); + + const delta = useDelta(time); + + useEffect(() => { + if (targetTime !== null && delta > 0 && time > targetTime) { + setTargetTime(null); + } + }, [time, delta, targetTime]); + + let fakeTime = time; + if (targetTime !== null) { + fakeTime = targetTime; + } + + return [fakeTime, setTargetTime] as const; +} diff --git a/packages/hlsjs/lib/ui/icons/forward.svg b/packages/hlsjs/lib/ui/icons/forward.svg new file mode 100644 index 00000000..fc2bd127 --- /dev/null +++ b/packages/hlsjs/lib/ui/icons/forward.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/hlsjs/lib/ui/index.scss b/packages/hlsjs/lib/ui/index.scss index d2583d74..b7c7fee7 100644 --- a/packages/hlsjs/lib/ui/index.scss +++ b/packages/hlsjs/lib/ui/index.scss @@ -22,5 +22,12 @@ $size-sm: 0.85em; inset: 0; width: 100%; height: 100%; + + // Subtitle styles + // For ref: https://opensource.apple.com/source/WebCore/WebCore-7606.4.5/Modules/modern-media-controls/controls/text-tracks.css.auto.html + + &::-webkit-media-text-track-container { + transform: scale(0.95); + } } } diff --git a/packages/hlsjs/lib/ui/utils.ts b/packages/hlsjs/lib/ui/utils.ts new file mode 100644 index 00000000..0ce646f1 --- /dev/null +++ b/packages/hlsjs/lib/ui/utils.ts @@ -0,0 +1,24 @@ +export function toHMS(seconds: number) { + const pad = (value: number) => + (10 ** 2 + Math.floor(value)).toString().substring(1); + + seconds = Math.floor(seconds); + if (seconds < 0) { + seconds = 0; + } + + let result = ""; + + const h = Math.trunc(seconds / 3600) % 24; + if (h) { + result += `${pad(h)}:`; + } + + const m = Math.trunc(seconds / 60) % 60; + result += `${pad(m)}:`; + + const s = Math.trunc(seconds % 60); + result += `${pad(s)}`; + + return result; +} diff --git a/packages/hlsjs/src/main.tsx b/packages/hlsjs/src/main.tsx index 784dcdd8..23b85112 100644 --- a/packages/hlsjs/src/main.tsx +++ b/packages/hlsjs/src/main.tsx @@ -14,7 +14,9 @@ Object.assign(window, { facade }); // hls.config.startPosition = 10; hls.loadSource( - "https://streamer.ams3.cdn.digitaloceanspaces.com/package/846ed9ef-b11f-43a4-9d31-0cecc1b7468c/hls/master.m3u8", + // "https://streamer.ams3.cdn.digitaloceanspaces.com/package/846ed9ef-b11f-43a4-9d31-0cecc1b7468c/hls/master.m3u8", + "https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/redundant.m3u8", + // "http://127.0.0.1:52002/session/260c015b-966f-4cc4-8373-ab859379a27d/master.m3u8", ); const root = ReactDOM.createRoot(document.getElementById("root")!);