diff --git a/apps/api-demo/.gitignore b/apps/api-demo/.gitignore new file mode 100644 index 000000000..a422a7229 --- /dev/null +++ b/apps/api-demo/.gitignore @@ -0,0 +1,15 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +pnpm-debug.log* + +# macOS-specific files +.DS_Store diff --git a/apps/api-demo/README.md b/apps/api-demo/README.md new file mode 100644 index 000000000..633fc4e53 --- /dev/null +++ b/apps/api-demo/README.md @@ -0,0 +1,47 @@ +# @videojs/api-demo + +An Astro demo app that showcases the Video.js 10 **media API**. + +The homepage renders the React `HlsJsVideo` player (with the default skin) and a +panel of controls wired directly to the media instance via `useMedia()`. It is a +hands-on playground for the media API: + +- **Source** — load any HLS (`.m3u8`) URL for testing. +- **Setters / actions** — play/pause, seek (slider + exact time), playback rate, + volume, mute, and text/audio track selection, each calling the API directly + (`media.play()`, `media.currentTime = …`, `media.textTracks[i].mode = …`, …). +- **Getters** — a cloud of every readable property; click one to log its current + value. +- **Message log** — a live, color-coded console: media **events** (yellow), + **actions** (orange), and **getter** reads (magenta). +- **Shareable state** — every action is written to the URL as a query param + (booleans as `0`/`1`), so a configuration can be shared and is restored on + reload. + +Styling, palette, typography (Instrument Sans / IBM Plex Mono / Eurostile), and +footer mirror videojs.org so the page shares the same look and feel. + +## Develop + +```bash +# from the repo root +pnpm --filter @videojs/api-demo dev + +# or build the workspace packages first, then run directly +pnpm build:packages +pnpm --dir apps/api-demo dev +``` + +## Build + +```bash +pnpm --filter @videojs/api-demo build +``` + +## Adding more API demos + +`src/components/PlayerDemo.tsx` is the place to grow this. The media instance +returned by `useMedia()` exposes the full media API (seeking, volume, playback +rate, tracks, stream type, renditions, …) — add new controls alongside the +existing ones, log them as actions, and persist them to the URL to match the +rest of the demo. diff --git a/apps/api-demo/astro.config.mjs b/apps/api-demo/astro.config.mjs new file mode 100644 index 000000000..ad7a0c54d --- /dev/null +++ b/apps/api-demo/astro.config.mjs @@ -0,0 +1,23 @@ +// @ts-check +import react from '@astrojs/react'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [react()], + vite: { + plugins: [tailwindcss()], + optimizeDeps: { + // The workspace media packages ship ESM; let Vite resolve them directly + // instead of pre-bundling (mirrors the main site config). + exclude: ['@videojs/core', '@videojs/react'], + // react-dom (CJS) must be pre-bundled so its named exports are exposed as + // ESM bindings to the @astrojs/react client renderer. + include: ['react-dom', 'react-dom/client'], + }, + resolve: { + dedupe: ['react', 'react-dom'], + }, + }, +}); diff --git a/apps/api-demo/env.d.ts b/apps/api-demo/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/apps/api-demo/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/api-demo/package.json b/apps/api-demo/package.json new file mode 100644 index 000000000..67e79b16e --- /dev/null +++ b/apps/api-demo/package.json @@ -0,0 +1,30 @@ +{ + "name": "@videojs/api-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check" + }, + "dependencies": { + "@videojs/core": "workspace:*", + "@videojs/react": "workspace:*", + "astro": "^6.3.1", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@astrojs/check": "^0.9.8", + "@astrojs/react": "^5.0.2", + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "tailwindcss": "^4.2.1", + "typescript": "npm:@typescript/typescript6@^6.0.0", + "vite": "^7.0.0" + } +} diff --git a/apps/api-demo/src/components/Footer.astro b/apps/api-demo/src/components/Footer.astro new file mode 100644 index 000000000..6cb7c83dc --- /dev/null +++ b/apps/api-demo/src/components/Footer.astro @@ -0,0 +1,61 @@ +--- +// Footer adapted from videojs.org (site/src/components/Footer.astro): the mono +// logo on the left and the right-aligned descriptive text with links. +const year = new Date().getFullYear(); +const VIDEOJS_URL = 'https://videojs.org'; +const MUX_URL = 'https://www.mux.com'; +--- + +
+
+
+ + + +
+

+ Video.js is a free and open source HTML5 video player. +
+ Video hosting and streaming sponsored by Mux +

+ The term VIDEO.JS is a registered trademark of Brightcove Inc. +
+ © 2010–{year} Video.js contributors +

+
+ + {/* Decorative colored bars, flush to the bottom edge. */} + +
diff --git a/apps/api-demo/src/components/PlayerDemo.tsx b/apps/api-demo/src/components/PlayerDemo.tsx new file mode 100644 index 000000000..1775b5c24 --- /dev/null +++ b/apps/api-demo/src/components/PlayerDemo.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Demo } from './player-demo/demo'; +import { MediaLogProvider } from './player-demo/media-log'; +import { Player } from './player-demo/player'; + +export default function PlayerDemo() { + return ( + + + + + + ); +} diff --git a/apps/api-demo/src/components/ThemeInit.astro b/apps/api-demo/src/components/ThemeInit.astro new file mode 100644 index 000000000..5425140ca --- /dev/null +++ b/apps/api-demo/src/components/ThemeInit.astro @@ -0,0 +1,48 @@ +--- +import { THEME_COLOR, THEME_KEY } from '@/consts'; +--- + +{/* Dark mode initialization script - runs before paint to prevent FOUC. */} +{ + /* ts(2570) on define:vars-injected vars is expected — astro check can't see the injection (withastro/language-tools#711) */ +} + diff --git a/apps/api-demo/src/components/ThemeToggle.tsx b/apps/api-demo/src/components/ThemeToggle.tsx new file mode 100644 index 000000000..6abba912b --- /dev/null +++ b/apps/api-demo/src/components/ThemeToggle.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { type ReactNode, useEffect, useState } from 'react'; +import { THEME_COLOR, THEME_KEY } from '@/consts'; + +type Preference = 'system' | 'light' | 'dark'; +type Theme = 'light' | 'dark'; + +function ComputerIcon() { + return ( + + ); +} + +function SunIcon() { + return ( + + ); +} + +function MoonIcon() { + return ( + + ); +} + +const themeOptions: { value: Preference; icon: ReactNode; label: string }[] = [ + { value: 'system', icon: , label: 'System' }, + { value: 'light', icon: , label: 'Light' }, + { value: 'dark', icon: , label: 'Dark' }, +]; + +function initPreference(): Preference { + if (typeof localStorage === 'undefined') return 'system'; + if (localStorage[THEME_KEY] === 'light') return 'light'; + if (localStorage[THEME_KEY] === 'dark') return 'dark'; + if (localStorage[THEME_KEY] === 'system') return 'system'; + // Shouldn't be possible after the head script runs, but handle it. + localStorage[THEME_KEY] = 'system'; + return 'system'; +} + +function getThemeFromPreference(preference: Preference): Theme { + if (preference === 'light') return 'light'; + if (preference === 'dark') return 'dark'; + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; +} + +export function ThemeToggle() { + const [preference, _setPreference] = useState(null); + const [theme, setTheme] = useState(null); + + const setPreference = (newPreference: Preference) => { + _setPreference(newPreference); + if (typeof localStorage !== 'undefined') localStorage[THEME_KEY] = newPreference; + setTheme(getThemeFromPreference(newPreference)); + }; + + // Initialize preference and theme on mount. + useEffect(() => { + const initialPreference = initPreference(); + _setPreference(initialPreference); + setTheme(getThemeFromPreference(initialPreference)); + }, []); + + // Track the system preference while 'system' is selected. + useEffect(() => { + if (preference !== 'system') return; + if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const onMediaChange = (event: MediaQueryListEvent) => setTheme(event.matches ? 'dark' : 'light'); + mediaQuery.addEventListener('change', onMediaChange); + + return () => mediaQuery.removeEventListener('change', onMediaChange); + }, [preference]); + + // Keep document.documentElement and the theme-color meta in sync with theme. + useEffect(() => { + if (typeof document === 'undefined' || theme === null) return; + const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + themeColorMeta?.setAttribute('content', THEME_COLOR.dark); + } else { + document.documentElement.classList.remove('dark'); + themeColorMeta?.setAttribute('content', THEME_COLOR.light); + } + }, [theme]); + + const disabled = preference === null; + + return ( +
+ {themeOptions.map((option) => { + const isPressed = preference === option.value; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx b/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx new file mode 100644 index 000000000..1638eb37a --- /dev/null +++ b/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx @@ -0,0 +1,68 @@ +import type { MediaFull } from '@videojs/core'; +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { type CSSProperties, useState } from 'react'; +import { useMediaLog } from './media-log'; +import { Player } from './player'; +import { NUMBER_INPUT_CLASS, SET_BUTTON_CLASS } from './styles'; + +const TIME_INPUT_STYLE: CSSProperties = { width: '5rem', flex: '0 0 auto' }; + +/** + * Add a cue point by appending to the `config.cuePoints` list (re-applied through + * the media config). The time field seeks the media as you change it; the entered + * value (or the current time when blank) becomes the cue point's `time`. + */ +export function AddCuePointField({ onAdd }: { onAdd: (cuePoint: CuePoint) => void }) { + const media = Player.useMedia() as MediaFull | null; + const { log } = useMediaLog(); + const [timeDraft, setTimeDraft] = useState(''); + const [valueDraft, setValueDraft] = useState(''); + + const changeTime = (raw: string) => { + setTimeDraft(raw); + const next = Number(raw); + if (media && raw.trim() !== '' && Number.isFinite(next)) media.currentTime = next; + }; + + return ( +
{ + event.preventDefault(); + const value = valueDraft.trim(); + if (!media || !value) return; + + const parsed = Number(timeDraft); + const time = timeDraft.trim() !== '' && Number.isFinite(parsed) ? parsed : media.currentTime; + onAdd({ time, value }); + log('action', `config.cuePoints += { time: ${time.toFixed(2)}, value: ${JSON.stringify(value)} }`); + setValueDraft(''); + }} + > + changeTime(event.target.value)} + className={NUMBER_INPUT_CLASS} + style={TIME_INPUT_STYLE} + /> + setValueDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/bar-slider.tsx b/apps/api-demo/src/components/player-demo/bar-slider.tsx new file mode 100644 index 000000000..d6127e148 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/bar-slider.tsx @@ -0,0 +1,84 @@ +import { type ComponentProps, useLayoutEffect, useRef, useState } from 'react'; + +// Roughly one bar every N pixels; the exact count is derived from the width. +const TARGET_SPACING = 4; +const MAX_BARS = 120; + +/** + * A range slider styled as a row of short vertical bars (a "tally bar"). Bars up + * to the current value are orange; the rest are muted. Hovering raises the bar + * under the pointer and its neighbors. A transparent native `` + * sits on top for interaction and accessibility — pointer events bubble to the + * container so we can still track which bar is hovered. + */ +export function BarSlider({ className, buffered, ...props }: ComponentProps<'input'> & { buffered?: number }) { + const min = Number(props.min ?? 0); + const max = Number(props.max ?? 100); + const value = Number(props.value ?? 0); + const ratio = max > min ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0; + const bufferedRatio = + buffered !== undefined && max > min ? Math.min(1, Math.max(0, (buffered - min) / (max - min))) : 0; + + const trackRef = useRef(null); + const [count, setCount] = useState(0); + const [hovered, setHovered] = useState(null); + + useLayoutEffect(() => { + const el = trackRef.current; + if (!el) return; + const update = () => setCount(Math.min(MAX_BARS, Math.max(8, Math.round(el.clientWidth / TARGET_SPACING)))); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const filled = Math.round(ratio * count); + const loaded = Math.round(bufferedRatio * count); + + const scaleFor = (index: number): number => { + if (hovered === null) return 0.8; + const dist = Math.abs(index - hovered); + if (dist === 0) return 1; + if (dist === 1) return 0.92; + if (dist === 2) return 0.86; + return 0.8; + }; + + return ( +
{ + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width === 0 || count === 0) return; + const index = Math.round(((event.clientX - rect.left) / rect.width) * (count - 1)); + setHovered(Math.min(count - 1, Math.max(0, index))); + }} + onPointerLeave={() => setHovered(null)} + > + {Array.from({ length: count }, (_, index) => { + const color = + index < filled + ? 'bg-orange' + : index < loaded + ? 'bg-faded-black/60 dark:bg-manila-light/65' + : 'bg-faded-black/35 dark:bg-manila-light/40'; + return ( +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/constants.ts b/apps/api-demo/src/components/player-demo/constants.ts new file mode 100644 index 000000000..dea32b4ca --- /dev/null +++ b/apps/api-demo/src/components/player-demo/constants.ts @@ -0,0 +1,53 @@ +import type { MediaFullEvents } from '@videojs/core'; +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; + +export const DEFAULT_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8'; + +// Default cue points, applied to the media through the `cuePoints` config +// namespace and readable back via the CuePoints component API. +export const DEFAULT_CUE_POINTS: CuePoint[] = [ + { time: 1, value: 'Simple Value' }, + { time: 3, value: { complex: 'Complex Object', duration: 2 } }, + { time: 10, value: true }, + { time: 15, value: { anything: 'That can be serialized to JSON and makes sense for your use case' } }, +]; + +// The most common media events. `timeupdate` fires continuously, so it is +// intentionally omitted to keep the log readable. +export const LOGGED_EVENTS = [ + 'loadstart', + 'loadedmetadata', + 'loadeddata', + 'canplay', + 'canplaythrough', + 'play', + 'playing', + 'pause', + 'waiting', + 'progress', + 'seeking', + 'seeked', + 'ratechange', + 'volumechange', + 'durationchange', + 'ended', + 'error', +] as const satisfies readonly (keyof MediaFullEvents)[]; + +// Events that may change the values mirrored in the controls panel. +export const STATE_EVENTS = [ + 'loadstart', + 'loadedmetadata', + 'emptied', + 'durationchange', + 'timeupdate', + 'play', + 'playing', + 'pause', + 'ended', + 'seeking', + 'seeked', + 'progress', + 'ratechange', + 'volumechange', +] as const satisfies readonly (keyof MediaFullEvents)[]; diff --git a/apps/api-demo/src/components/player-demo/controls.tsx b/apps/api-demo/src/components/player-demo/controls.tsx new file mode 100644 index 000000000..1d22789f8 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/controls.tsx @@ -0,0 +1,447 @@ +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { useEffect, useState } from 'react'; +import { AddCuePointField } from './add-cue-point-field'; +import { BarSlider } from './bar-slider'; +import { ApplyNumberField, Field } from './fields'; +import { cueValue, formatTime, num } from './format'; +import { CloseIcon, PauseIcon, PlayIcon } from './icons'; +import { useMediaLog } from './media-log'; +import { bool, setParam } from './params'; +import { Player, type TracksMedia } from './player'; +import { + ICON_BUTTON_CLASS, + PANEL_CLASS, + TE_SOCKET_CLASS, + TEXT_BUTTON_CLASS, + TEXT_BUTTON_ORANGE_CLASS, + TEXT_BUTTON_OUTLINE_CLASS, +} from './styles'; +import { AudioTrackSelect, QualitySelect, TextTrackSelect } from './track-selects'; +import { useMediaSnapshot, useRestoreFromParams } from './use-media-state'; + +/** + * The primary transport bar — play, seek, mute, volume, picture-in-picture, and + * fullscreen. Lives in its own bordered box so it can stay pinned under the + * player. Every interaction calls the media API directly and logs the call. + */ +export function TransportControls() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const snapshot = useMediaSnapshot(media); + const { paused, currentTime, duration, volume, muted, buffered } = snapshot; + + useRestoreFromParams(media); + + const [isFullscreen, setIsFullscreen] = useState(false); + const [isPictureInPicture, setIsPictureInPicture] = useState(false); + const [isLooping, setIsLooping] = useState(false); + const [remoteConnected, setRemoteConnected] = useState(false); + + // `loop` has no change event; re-read it when metadata (re)loads, e.g. after restore. + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + const sync = () => setIsLooping(Boolean(media.loop)); + media.addEventListener('loadedmetadata', sync, { signal: controller.signal }); + sync(); + return () => controller.abort(); + }, [media]); + + // Fullscreen has no dedicated media event; track it through the document. + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + const sync = () => setIsFullscreen(Boolean(media.isFullscreen)); + document.addEventListener('fullscreenchange', sync, { signal: controller.signal }); + document.addEventListener('webkitfullscreenchange', sync, { signal: controller.signal }); + sync(); + return () => controller.abort(); + }, [media]); + + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + const sync = () => setIsPictureInPicture(Boolean(media.isPictureInPicture)); + media.addEventListener('enterpictureinpicture', sync, { signal: controller.signal }); + media.addEventListener('leavepictureinpicture', sync, { signal: controller.signal }); + sync(); + return () => controller.abort(); + }, [media]); + + useEffect(() => { + if (!media) return; + const remote = media.remote; + const controller = new AbortController(); + const sync = () => setRemoteConnected(remote.state === 'connected'); + remote.addEventListener('connecting', sync, { signal: controller.signal }); + remote.addEventListener('connect', sync, { signal: controller.signal }); + remote.addEventListener('disconnect', sync, { signal: controller.signal }); + sync(); + return () => controller.abort(); + }, [media]); + + const hasDuration = Number.isFinite(duration) && duration > 0; + + const togglePlay = () => { + if (!media) return; + if (media.paused) { + media.play().catch(() => {}); + log('action', 'media.play()'); + setParam('paused', '0'); + } else { + media.pause(); + log('action', 'media.pause()'); + setParam('paused', '1'); + } + }; + + const toggleFullscreen = async () => { + if (!media) return; + try { + if (media.isFullscreen) { + await media.exitFullscreen(); + log('action', 'media.exitFullscreen()'); + } else { + await media.requestFullscreen(); + log('action', 'media.requestFullscreen()'); + } + } catch { + // Ignore rejected presentation requests (e.g. user gesture required). + } + }; + + const toggleLoop = () => { + if (!media) return; + const next = !media.loop; + media.loop = next; + setIsLooping(next); + log('action', `media.loop = ${next}`); + setParam('loop', bool(next)); + }; + + const togglePictureInPicture = async () => { + if (!media) return; + try { + if (media.isPictureInPicture) { + await media.exitPictureInPicture(); + log('action', 'media.exitPictureInPicture()'); + } else { + await media.requestPictureInPicture(); + log('action', 'media.requestPictureInPicture()'); + } + } catch { + // Ignore rejected presentation requests (e.g. user gesture required). + } + }; + + const promptRemote = () => { + if (!media) return; + media.remote.prompt().catch(() => { + // No remote playback devices available, or the prompt was dismissed. + }); + log('action', 'media.remote.prompt()'); + }; + + return ( +
+
+ + + + +
+ { + if (media) media.currentTime = Number(event.target.value); + }} + onPointerUp={() => { + if (!media) return; + log('action', `media.currentTime = ${media.currentTime.toFixed(1)}`); + setParam('time', media.currentTime.toFixed(1)); + }} + onKeyUp={() => { + if (!media) return; + log('action', `media.currentTime = ${media.currentTime.toFixed(1)}`); + setParam('time', media.currentTime.toFixed(1)); + }} + /> + + {formatTime(currentTime)} / {hasDuration ? formatTime(duration) : '–:––'} + +
+ + + + +
+ +
+ + + + +
+ { + if (!media) return; + media.volume = Number(event.target.value); + if (media.muted) media.muted = false; + }} + onPointerUp={() => { + if (!media) return; + log('action', `media.volume = ${media.volume.toFixed(2)}`); + setParam('volume', media.volume.toFixed(2)); + setParam('muted', bool(media.muted)); + }} + onKeyUp={() => { + if (!media) return; + log('action', `media.volume = ${media.volume.toFixed(2)}`); + setParam('volume', media.volume.toFixed(2)); + setParam('muted', bool(media.muted)); + }} + /> + + {Math.round((muted ? 0 : volume) * 100)}% + +
+
+ +
+ + + + + + + + + + + +
+
+ ); +} + +/** + * The secondary controls — precise setters, cue points, and track selects — + * split across two columns. Scrolls underneath the pinned transport bar. + */ +export function Controls({ + cuePoints, + onAddCuePoint, + onRemoveCuePoint, +}: { + cuePoints: CuePoint[]; + onAddCuePoint: (cuePoint: CuePoint) => void; + onRemoveCuePoint: (index: number) => void; +}) { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + + const seekToCuePoint = (time: number) => { + if (!media) return; + media.currentTime = time; + log('action', `media.currentTime = ${num(time)}`); + setParam('time', String(time)); + }; + + return ( +
+ {/* Setters */} +
+ + { + if (!media) return; + media.currentTime = value; + log('action', `media.currentTime = ${value}`); + setParam('time', String(value)); + }} + /> + + + + { + if (!media || value <= 0) return; + media.playbackRate = value; + log('action', `media.playbackRate = ${value}`); + setParam('rate', String(value)); + }} + /> + +
+ + {/* Track selects */} +
+ + + + + + + + + + + +
+ + {/* Cue points */} +
+ + + {cuePoints.length > 0 && ( + + + + + + + + + + {cuePoints.map((cuePoint, index) => ( + // biome-ignore lint/a11y/useSemanticElements: a table row can't be a seekToCuePoint(cuePoint.time)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + seekToCuePoint(cuePoint.time); + } + }} + className="cursor-pointer border-b border-manila-dark transition-colors hover:bg-manila-25 dark:border-warm-gray dark:hover:bg-soot" + > + + + + + ))} + +
+ Time + + Value + + Actions +
+ {num(cuePoint.time)}s + + {cueValue(cuePoint.value)} + + +
+ )} +
+
+
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/demo.tsx b/apps/api-demo/src/components/player-demo/demo.tsx new file mode 100644 index 000000000..d07fc4e80 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/demo.tsx @@ -0,0 +1,108 @@ +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { HlsJsVideo } from '@videojs/react/media/hlsjs-video'; +import { useMemo, useState } from 'react'; +import { DEFAULT_CUE_POINTS } from './constants'; +import { Controls, TransportControls } from './controls'; +import { EventLog } from './event-log'; +import { Field, SourceField } from './fields'; +import { quote } from './format'; +import { Getters } from './getters'; +import { useMediaLog } from './media-log'; +import { getInitialSrc, posterFor, readParams, resetParamsToSrc, setParam } from './params'; +import { Player, type TracksMedia } from './player'; +import { SELECT_CLASS } from './styles'; + +type Preload = 'none' | 'metadata' | 'auto'; + +function getInitialPreload(): Preload { + const value = readParams().get('preload'); + return value === 'none' || value === 'metadata' || value === 'auto' ? value : 'metadata'; +} + +/** Inner demo body — lives inside the player + log providers. */ +export function Demo() { + const { log } = useMediaLog(); + const media = Player.useMedia() as TracksMedia | null; + const [src, setSrc] = useState(getInitialSrc); + const [preload, setPreload] = useState(getInitialPreload); + const [cuePoints, setCuePoints] = useState(DEFAULT_CUE_POINTS); + + // Cue points are applied through the media config; appending here re-applies + // the full list via the `cuePoints` config namespace. + const mediaConfig = useMemo(() => ({ cuePoints: { cuePoints } }), [cuePoints]); + const addCuePoint = (cuePoint: CuePoint) => setCuePoints((prev) => [...prev, cuePoint]); + const removeCuePoint = (index: number) => setCuePoints((prev) => prev.filter((_, i) => i !== index)); + + const loadSrc = (next: string) => { + setSrc(next); + // A new asset starts fresh — drop every other persisted param except preload. + resetParamsToSrc(next, { preload }); + // Reset the media element's state so the new source plays from a clean slate. + if (media) { + media.currentTime = 0; + media.playbackRate = 1; + media.volume = 1; + media.muted = false; + media.loop = false; + } + log('action', `media.src = ${quote(next)}`); + }; + + const changePreload = (next: Preload) => { + setPreload(next); + log('action', `media.preload = ${quote(next)}`); + setParam('preload', next); + }; + + return ( +
+ {/* Player + transport controls pin together; the rest scrolls under them */} +
+
+
+ +
+ +
+ +
+ + {/* Source + message log + getters */} + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/event-log.tsx b/apps/api-demo/src/components/player-demo/event-log.tsx new file mode 100644 index 000000000..1cbf70d50 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/event-log.tsx @@ -0,0 +1,76 @@ +import type { MediaFull } from '@videojs/core'; +import { useEffect, useRef } from 'react'; +import { LOGGED_EVENTS } from './constants'; +import { entryColorClass, useMediaLog } from './media-log'; +import { Player } from './player'; +import { bufferedRatio } from './use-media-state'; + +/** + * Renders the live message log. Media events are logged automatically (yellow); + * the controls push their API calls as actions (orange); getter reads are + * logged as values (magenta). + */ +export function EventLog() { + const media = Player.useMedia() as MediaFull | null; + const { entries, log, clear } = useMediaLog(); + const listRef = useRef(null); + + useEffect(() => { + if (!media) return; + + const controller = new AbortController(); + for (const type of LOGGED_EVENTS) { + const handler = + type === 'progress' + ? () => log('event', `progress → ${Math.round(bufferedRatio(media) * 100)}% loaded`) + : () => log('event', type); + media.addEventListener(type, handler, { signal: controller.signal }); + } + + // `cuepointchange` is a custom event dispatched by the CuePoints component; + // its `detail` is the activated cue point. + (media as unknown as EventTarget).addEventListener( + 'cuepointchange', + (event) => log('event', `cuepointchange → ${JSON.stringify((event as CustomEvent).detail)}`), + { signal: controller.signal } + ); + + return () => controller.abort(); + }, [media, log]); + + // Keep the newest entry in view whenever a new one is appended. + useEffect(() => { + const el = listRef.current; + if (el && entries.length) el.scrollTop = el.scrollHeight; + }, [entries.length]); + + return ( +
+
+ Message Log + +
+
    + {entries.length === 0 ? ( +
  1. Waiting for media events…
  2. + ) : ( + entries.map((entry) => ( +
  3. + {entry.time} + + {entry.kind === 'action' ? '▸ ' : ''} + {entry.label} + +
  4. + )) + )} +
+
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/fields.tsx b/apps/api-demo/src/components/player-demo/fields.tsx new file mode 100644 index 000000000..09537e2a2 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/fields.tsx @@ -0,0 +1,92 @@ +import { type ReactNode, useState } from 'react'; +import { NUMBER_INPUT_CLASS, SET_BUTTON_CLASS } from './styles'; + +/** A labeled section in the controls panel. */ +export function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +/** + * A number text field with a "Set" button. Holds its own draft value so the + * live media value doesn't fight what the user is typing. + */ +export function ApplyNumberField({ + placeholder, + step, + min, + ariaLabel, + disabled, + onApply, +}: { + placeholder: string; + step?: string; + min?: string; + ariaLabel: string; + disabled?: boolean; + onApply: (value: number) => void; +}) { + const [draft, setDraft] = useState(''); + + return ( +
{ + event.preventDefault(); + const value = Number(draft); + if (draft.trim() !== '' && Number.isFinite(value)) onApply(value); + setDraft(''); + }} + > + setDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} + +/** Source URL input with a Load button for testing arbitrary assets. */ +export function SourceField({ src, onLoad }: { src: string; onLoad: (next: string) => void }) { + const [draft, setDraft] = useState(src); + + return ( +
{ + event.preventDefault(); + const next = draft.trim(); + if (next) onLoad(next); + }} + > + setDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/format.ts b/apps/api-demo/src/components/player-demo/format.ts new file mode 100644 index 000000000..6dba6c513 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/format.ts @@ -0,0 +1,31 @@ +import type { TimeRangeLike } from '@videojs/core'; + +export function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return '0:00'; + const total = Math.max(0, Math.floor(seconds)); + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +} + +export function num(value: number): string { + if (Number.isNaN(value)) return 'NaN'; + if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity'; + return Number.isInteger(value) ? String(value) : value.toFixed(3); +} + +export function quote(value: string): string { + return `"${value}"`; +} + +/** Render a cue point's value for display: strings as-is, everything else as JSON. */ +export function cueValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function ranges(value: TimeRangeLike): string { + if (!value || value.length === 0) return '(empty)'; + const parts: string[] = []; + for (let i = 0; i < value.length; i++) parts.push(`${num(value.start(i))}–${num(value.end(i))}`); + return `[${parts.join(', ')}]`; +} diff --git a/apps/api-demo/src/components/player-demo/getters.tsx b/apps/api-demo/src/components/player-demo/getters.tsx new file mode 100644 index 000000000..63c8c0ea6 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/getters.tsx @@ -0,0 +1,94 @@ +import { num, quote, ranges } from './format'; +import { useMediaLog } from './media-log'; +import { Player, type TracksMedia } from './player'; + +// Every readable property on the media instance. Clicking one logs its current +// value to the message log. +const GETTERS: { expr: string; read: (media: TracksMedia) => string }[] = [ + { expr: 'media.paused', read: (m) => String(m.paused) }, + { expr: 'media.ended', read: (m) => String(m.ended) }, + { expr: 'media.seeking', read: (m) => String(m.seeking) }, + { expr: 'media.currentTime', read: (m) => num(m.currentTime) }, + { expr: 'media.duration', read: (m) => num(m.duration) }, + { expr: 'media.videoWidth', read: (m) => num(m.videoWidth) }, + { expr: 'media.videoHeight', read: (m) => num(m.videoHeight) }, + { expr: 'media.volume', read: (m) => num(m.volume) }, + { expr: 'media.muted', read: (m) => String(m.muted) }, + { expr: 'media.defaultMuted', read: (m) => String(m.defaultMuted) }, + { expr: 'media.playbackRate', read: (m) => num(m.playbackRate) }, + { expr: 'media.defaultPlaybackRate', read: (m) => num(m.defaultPlaybackRate) }, + { expr: 'media.readyState', read: (m) => String(m.readyState) }, + { expr: 'media.src', read: (m) => quote(m.src) }, + { expr: 'media.currentSrc', read: (m) => quote(m.currentSrc) }, + { expr: 'media.preload', read: (m) => quote(m.preload) }, + { expr: 'media.crossOrigin', read: (m) => (m.crossOrigin === null ? 'null' : quote(m.crossOrigin)) }, + { expr: 'media.streamType', read: (m) => quote(m.streamType) }, + { expr: 'media.poster', read: (m) => quote(m.poster) }, + { expr: 'media.playsInline', read: (m) => String(m.playsInline) }, + { expr: 'media.loop', read: (m) => String(m.loop) }, + { expr: 'media.autoplay', read: (m) => String(m.autoplay) }, + { expr: 'media.controls', read: (m) => String(m.controls) }, + { expr: 'media.isFullscreen', read: (m) => String(m.isFullscreen) }, + { expr: 'media.isPictureInPicture', read: (m) => String(m.isPictureInPicture) }, + { expr: 'media.disablePictureInPicture', read: (m) => String(m.disablePictureInPicture) }, + { expr: 'media.disableRemotePlayback', read: (m) => String(m.disableRemotePlayback) }, + { expr: 'media.liveEdgeStart', read: (m) => num(m.liveEdgeStart) }, + { expr: 'media.targetLiveWindow', read: (m) => num(m.targetLiveWindow) }, + { expr: 'media.buffered', read: (m) => ranges(m.buffered) }, + { expr: 'media.seekable', read: (m) => ranges(m.seekable) }, + { expr: 'media.played', read: (m) => ranges(m.played) }, + { expr: 'media.textTracks', read: (m) => `${m.textTracks.length} track(s)` }, + { + expr: 'media.config.cuePoints.cuePoints', + read: (m) => { + const ns = m.config.cuePoints as { cuePoints?: unknown } | undefined; + return JSON.stringify(ns?.cuePoints ?? []); + }, + }, + + { + expr: 'media.videoRenditions', + read: (m) => { + const list = m.videoRenditions; + if (!list) return 'undefined'; + const items: string[] = []; + for (let i = 0; i < list.length; i++) items.push(list[i]!.height ? `${list[i]!.height}p` : `#${i}`); + return `[${items.join(', ')}] selectedIndex=${list.selectedIndex}`; + }, + }, + { expr: 'media.remote.state', read: (m) => quote(m.remote.state) }, + { + expr: 'media.error', + read: (m) => (m.error ? `{ code: ${m.error.code}, message: ${quote(m.error.message)} }` : 'null'), + }, +]; + +/** + * Lists every readable media property. Clicking one reads it live from the + * media instance and logs `expr → value` to the message log. + */ +export function Getters() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + + return ( +
+

Click a getter to log its current value.

+
+ {GETTERS.map((getter) => ( + + ))} +
+
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/icons.tsx b/apps/api-demo/src/components/player-demo/icons.tsx new file mode 100644 index 000000000..4e4b675b5 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/icons.tsx @@ -0,0 +1,60 @@ +/** + * Button glyphs. We use plain unicode characters (rendered in the button's + * current color) instead of SVGs to keep the controls lightweight and simple. + */ +// Render symbol glyphs with the system UI / symbol fonts (not the page's display +// font) so characters like ►, ↻, and ⛶ render cleanly across platforms. +const GLYPH_FONT = '"Arial", sans-serif'; + +function Glyph({ children, className }: { children: string; className?: string }) { + return ( + + ); +} + +export function PlayIcon() { + return ; +} + +export function PauseIcon() { + return ; +} + +export function VolumeIcon() { + return ; +} + +export function MuteIcon() { + // Music note with a combining slash overlay = muted. + return {'♪\u0338'}; +} + +export function LoopIcon() { + return ; +} + +export function CloseIcon() { + return ×; +} + +export function FullscreenEnterIcon() { + return ; +} + +export function FullscreenExitIcon() { + return ; +} + +export function PipEnterIcon() { + return ; +} + +export function PipExitIcon() { + return ; +} + +export function RemoteIcon() { + return ; +} diff --git a/apps/api-demo/src/components/player-demo/media-log.tsx b/apps/api-demo/src/components/player-demo/media-log.tsx new file mode 100644 index 000000000..e219f3247 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/media-log.tsx @@ -0,0 +1,50 @@ +import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; + +export type LogKind = 'event' | 'action' | 'getter'; + +export interface LogEntry { + id: number; + time: string; + label: string; + kind: LogKind; +} + +export interface MediaLog { + entries: LogEntry[]; + log: (kind: LogKind, label: string) => void; + clear: () => void; +} + +const MediaLogContext = createContext(null); + +export function useMediaLog(): MediaLog { + const ctx = useContext(MediaLogContext); + if (!ctx) throw new Error('useMediaLog must be used within a MediaLogProvider'); + return ctx; +} + +export function MediaLogProvider({ children }: { children: ReactNode }) { + const [entries, setEntries] = useState([]); + const nextId = useRef(0); + + const log = useCallback((kind: LogKind, label: string) => { + const time = new Date().toLocaleTimeString(undefined, { hour12: false }); + setEntries((prev) => { + const next = [...prev, { id: nextId.current++, time, label, kind }]; + // Keep the log bounded so long sessions don't grow unbounded. + return next.length > 100 ? next.slice(-100) : next; + }); + }, []); + + const clear = useCallback(() => setEntries([]), []); + + const value = useMemo(() => ({ entries, log, clear }), [entries, log, clear]); + + return {children}; +} + +export function entryColorClass(kind: LogKind): string { + if (kind === 'action') return 'text-orange'; + if (kind === 'getter') return 'text-magenta'; + return 'text-bright-yellow'; +} diff --git a/apps/api-demo/src/components/player-demo/params.ts b/apps/api-demo/src/components/player-demo/params.ts new file mode 100644 index 000000000..03e072d27 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/params.ts @@ -0,0 +1,44 @@ +import { DEFAULT_SRC } from './constants'; + +// Actions are persisted as query params so a configuration can be shared / +// restored. Booleans use "0" / "1". + +export function readParams(): URLSearchParams { + if (typeof window === 'undefined') return new URLSearchParams(); + return new URLSearchParams(window.location.search); +} + +export function setParam(key: string, value: string | null): void { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + if (value === null || value === '') url.searchParams.delete(key); + else url.searchParams.set(key, value); + window.history.replaceState(window.history.state, '', url); +} + +export function bool(value: boolean): string { + return value ? '1' : '0'; +} + +/** + * Replace the query string with just `src` (plus any `keep` entries), dropping + * every other persisted param. + */ +export function resetParamsToSrc(src: string, keep?: Record): void { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + url.search = ''; + url.searchParams.set('src', src); + if (keep) for (const [key, value] of Object.entries(keep)) url.searchParams.set(key, value); + window.history.replaceState(window.history.state, '', url); +} + +export function getInitialSrc(): string { + return readParams().get('src') || DEFAULT_SRC; +} + +/** Derive a Mux poster image from a Mux stream URL, when applicable. */ +export function posterFor(src: string): string | undefined { + const match = src.match(/stream\.mux\.com\/([^/.]+)\.m3u8/); + return match ? `https://image.mux.com/${match[1]}/thumbnail.webp` : undefined; +} diff --git a/apps/api-demo/src/components/player-demo/player.ts b/apps/api-demo/src/components/player-demo/player.ts new file mode 100644 index 000000000..22201b87b --- /dev/null +++ b/apps/api-demo/src/components/player-demo/player.ts @@ -0,0 +1,11 @@ +import type { AudioTrackListLike, Video, VideoRenditionListLike } from '@videojs/core'; +import { createPlayer, videoFeatures } from '@videojs/react'; + +export const Player = createPlayer({ features: videoFeatures }); + +// Audio tracks and video renditions are only exposed once the hls.js engine attaches. +// Fullscreen / picture-in-picture live on the video media surface. +export type TracksMedia = Video & { + readonly audioTracks?: AudioTrackListLike; + readonly videoRenditions?: VideoRenditionListLike; +}; diff --git a/apps/api-demo/src/components/player-demo/styles.ts b/apps/api-demo/src/components/player-demo/styles.ts new file mode 100644 index 000000000..571269a90 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/styles.ts @@ -0,0 +1,32 @@ +export const PANEL_CLASS = 'border border-faded-black bg-manila-light dark:border-manila-light dark:bg-faded-black'; + +// Square black socket that a `te-button` key sits in (TE-style recessed well). +export const TE_SOCKET_CLASS = 'inline-flex shrink-0 bg-black p-px shadow-[0_1px_2px_rgba(0,0,0,0.4)]'; + +export const ICON_BUTTON_CLASS = + 'te-button inline-flex size-11 shrink-0 cursor-pointer items-center justify-center bg-faded-black text-manila-light transition-colors hover:bg-soot focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-faded-black disabled:cursor-not-allowed disabled:opacity-50 dark:bg-manila-light dark:text-faded-black dark:hover:bg-manila-dark dark:focus-visible:outline-manila-light'; + +// Text label buttons (loop / mute / pip / remote / fullscreen): word at the top, +// centered, monospace all-caps, uniform square width. Filled = active. +export const TEXT_BUTTON_CLASS = + 'te-button inline-flex h-11 w-11 shrink-0 cursor-pointer items-start justify-center border border-transparent px-0.5 pt-1.5 text-center font-mono text-[10px] font-semibold uppercase leading-none tracking-tight bg-faded-black text-manila-light transition-colors hover:bg-soot focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-faded-black disabled:cursor-not-allowed disabled:opacity-50 dark:bg-manila-light dark:text-faded-black dark:hover:bg-manila-dark dark:focus-visible:outline-manila-light'; + +// Bright-orange active key (used for the unmuted state). +export const TEXT_BUTTON_ORANGE_CLASS = + 'te-button inline-flex h-11 w-11 shrink-0 cursor-pointer items-start justify-center border border-transparent px-0.5 pt-1.5 text-center font-mono text-[10px] font-semibold uppercase leading-none tracking-tight bg-orange text-manila-light transition-colors hover:bg-gold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-faded-black disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-manila-light'; + +export const TEXT_BUTTON_OUTLINE_CLASS = + 'te-button inline-flex h-11 w-11 shrink-0 cursor-pointer items-start justify-center border border-transparent px-0.5 pt-1.5 text-center font-mono text-[10px] font-semibold uppercase leading-none tracking-tight bg-manila-50 text-faded-black transition-colors hover:bg-manila-light focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-faded-black disabled:cursor-not-allowed disabled:opacity-50 dark:bg-warm-gray dark:text-manila-light dark:hover:bg-soot dark:focus-visible:outline-manila-light'; + +// Unpressed state for icon toggle buttons (e.g. loop): outlined instead of filled. +export const ICON_BUTTON_OUTLINE_CLASS = + 'inline-flex size-11 shrink-0 cursor-pointer items-center justify-center border border-faded-black text-faded-black transition-colors hover:bg-faded-black hover:text-manila-light focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-faded-black disabled:cursor-not-allowed disabled:opacity-50 dark:border-manila-light dark:text-manila-light dark:hover:bg-manila-light dark:hover:text-faded-black dark:focus-visible:outline-manila-light'; + +export const NUMBER_INPUT_CLASS = + 'w-full rounded-xs border border-manila-dark bg-manila-50 px-3 py-2 font-mono text-sm text-faded-black focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-orange dark:border-warm-gray dark:bg-warm-gray dark:text-manila-light dark:placeholder:text-manila-dark'; + +export const SET_BUTTON_CLASS = + 'shrink-0 cursor-pointer rounded-xs border border-manila-dark px-3 py-2 font-display text-xs uppercase tracking-wide transition-colors hover:bg-faded-black hover:text-manila-light disabled:cursor-not-allowed disabled:opacity-50 dark:border-warm-gray dark:hover:bg-manila-light dark:hover:text-faded-black'; + +export const SELECT_CLASS = + 'select-chevron w-full cursor-pointer appearance-none rounded-xs border border-manila-dark bg-manila-50 px-3 py-2 pr-9 text-sm text-faded-black transition-colors hover:bg-manila-dark focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-orange disabled:cursor-not-allowed disabled:opacity-50 dark:border-warm-gray dark:bg-warm-gray dark:text-manila-light dark:hover:bg-soot'; diff --git a/apps/api-demo/src/components/player-demo/track-selects.tsx b/apps/api-demo/src/components/player-demo/track-selects.tsx new file mode 100644 index 000000000..7d1c3b249 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/track-selects.tsx @@ -0,0 +1,254 @@ +import type { AudioTrackListLike, TextTrackListLike } from '@videojs/core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMediaLog } from './media-log'; +import { readParams, setParam } from './params'; +import { Player, type TracksMedia } from './player'; +import { SELECT_CLASS } from './styles'; + +function trackLabel(track: { label: string; language: string }, index: number): string { + return track.label || track.language || `Track ${index + 1}`; +} + +function applyTextTrack(list: TextTrackListLike, selected: number): void { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + if (track.kind === 'subtitles' || track.kind === 'captions') { + track.mode = i === selected ? 'showing' : 'disabled'; + } + } +} + +function applyAudioTrack(list: AudioTrackListLike, selected: number): void { + for (let i = 0; i < list.length; i++) list[i]!.enabled = i === selected; +} + +/** Dropdown for selecting the video rendition (quality), or Auto for ABR. */ +export function QualitySelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.videoRenditions ?? null; + + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('quality'); + if (param !== null) list.selectedIndex = param === 'auto' ? -1 : Number(param); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addrendition', onEvent, { signal: controller.signal }); + list.addEventListener('removerendition', onEvent, { signal: controller.signal }); + list.addEventListener('activechange', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + if (list) { + for (let i = 0; i < list.length; i++) { + const rendition = list[i]!; + const label = rendition.height + ? `${rendition.height}p` + : rendition.width + ? `${rendition.width}w` + : `Rendition ${i + 1}`; + options.push({ value: String(i), label }); + } + } + const value = list && list.selectedIndex >= 0 ? String(list.selectedIndex) : 'auto'; + + const onChange = (next: string) => { + if (!list) return; + const index = next === 'auto' ? -1 : Number(next); + list.selectedIndex = index; + log('action', `media.videoRenditions.selectedIndex = ${index}`); + setParam('quality', next); + }; + + return ( + + ); +} + +/** Dropdown for selecting the showing subtitles/captions text track. */ +export function TextTrackSelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.textTracks ?? null; + + // Tracks can appear after load, so re-read on media load events. + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('texttrack'); + if (param !== null) applyTextTrack(list, param === 'off' ? -1 : Number(param)); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addtrack', onEvent, { signal: controller.signal }); + list.addEventListener('removetrack', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + let showing = 'off'; + if (list) { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + if (track.kind === 'subtitles' || track.kind === 'captions') { + options.push({ value: String(i), label: trackLabel(track, i) }); + if (track.mode === 'showing') showing = String(i); + } + } + } + + const onChange = (value: string) => { + if (!list) return; + const selected = value === 'off' ? -1 : Number(value); + applyTextTrack(list, selected); + log('action', selected < 0 ? 'text track → "off"' : `media.textTracks[${selected}].mode = "showing"`); + setParam('texttrack', value); + }; + + return ( + + ); +} + +/** Dropdown for selecting the enabled audio track (hls.js engine only). */ +export function AudioTrackSelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.audioTracks ?? null; + + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('audiotrack'); + if (param !== null) applyAudioTrack(list, Number(param)); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addtrack', onEvent, { signal: controller.signal }); + list.addEventListener('removetrack', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + let enabled = -1; + if (list) { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + options.push({ value: String(i), label: trackLabel(track, i) }); + if (track.enabled) enabled = i; + } + } + const value = options.length === 0 ? '' : String(enabled >= 0 ? enabled : 0); + + const onChange = (next: string) => { + if (!list) return; + const selected = Number(next); + applyAudioTrack(list, selected); + log('action', `media.audioTracks[${selected}].enabled = true`); + setParam('audiotrack', next); + }; + + return ( + + ); +} diff --git a/apps/api-demo/src/components/player-demo/use-media-state.ts b/apps/api-demo/src/components/player-demo/use-media-state.ts new file mode 100644 index 000000000..2dfaa7662 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/use-media-state.ts @@ -0,0 +1,108 @@ +import type { MediaFull } from '@videojs/core'; +import { useEffect, useState } from 'react'; +import { STATE_EVENTS } from './constants'; +import { readParams } from './params'; + +export interface MediaSnapshot { + paused: boolean; + currentTime: number; + duration: number; + playbackRate: number; + volume: number; + muted: boolean; + buffered: number; +} + +const DEFAULT_SNAPSHOT: MediaSnapshot = { + paused: true, + currentTime: 0, + duration: 0, + playbackRate: 1, + volume: 1, + muted: false, + buffered: 0, +}; + +/** End (in seconds) of the buffered range covering the current time; 0 if none. */ +export function bufferedEnd(media: MediaFull): number { + const ranges = media.buffered; + if (!ranges || ranges.length === 0) return 0; + const time = media.currentTime; + for (let i = 0; i < ranges.length; i++) { + if (ranges.start(i) <= time && time <= ranges.end(i)) return ranges.end(i); + } + return ranges.end(ranges.length - 1); +} + +/** Fraction (0–1) of the media duration that has buffered ahead of the current time. */ +export function bufferedRatio(media: MediaFull): number { + const duration = media.duration; + if (!Number.isFinite(duration) || duration <= 0) return 0; + return Math.min(1, Math.max(0, bufferedEnd(media) / duration)); +} + +function readSnapshot(media: MediaFull): MediaSnapshot { + return { + paused: media.paused, + currentTime: media.currentTime, + duration: media.duration, + playbackRate: media.playbackRate, + volume: media.volume, + muted: media.muted, + buffered: bufferedEnd(media), + }; +} + +/** Subscribe to the events that affect the controls and return a live snapshot. */ +export function useMediaSnapshot(media: MediaFull | null): MediaSnapshot { + const [snapshot, setSnapshot] = useState(DEFAULT_SNAPSHOT); + + useEffect(() => { + if (!media) { + setSnapshot(DEFAULT_SNAPSHOT); + return; + } + + const controller = new AbortController(); + const sync = () => setSnapshot(readSnapshot(media)); + + sync(); + for (const type of STATE_EVENTS) { + media.addEventListener(type, sync, { signal: controller.signal }); + } + + return () => controller.abort(); + }, [media]); + + return snapshot; +} + +/** Restore persisted action params onto the media each time metadata loads. */ +export function useRestoreFromParams(media: MediaFull | null) { + useEffect(() => { + if (!media) return; + + const controller = new AbortController(); + const apply = () => { + const params = readParams(); + + const rate = Number(params.get('rate')); + if (params.has('rate') && Number.isFinite(rate) && rate > 0) media.playbackRate = rate; + + const volume = Number(params.get('volume')); + if (params.has('volume') && Number.isFinite(volume)) media.volume = volume; + + if (params.has('muted')) media.muted = params.get('muted') === '1'; + + if (params.has('loop')) media.loop = params.get('loop') === '1'; + + const time = Number(params.get('time')); + if (params.has('time') && Number.isFinite(time)) media.currentTime = time; + + if (params.get('paused') === '0') media.play().catch(() => {}); + }; + + media.addEventListener('loadedmetadata', apply, { signal: controller.signal }); + return () => controller.abort(); + }, [media]); +} diff --git a/apps/api-demo/src/consts.ts b/apps/api-demo/src/consts.ts new file mode 100644 index 000000000..5f43e296b --- /dev/null +++ b/apps/api-demo/src/consts.ts @@ -0,0 +1,8 @@ +/** localStorage key for the persisted color-theme preference. */ +export const THEME_KEY = 'vjs-api-demo-theme'; + +/** `theme-color` meta values, matched to the demo's light/dark page backgrounds. */ +export const THEME_COLOR = { + light: '#f3e7d2', + dark: '#1e1d1d', +} as const; diff --git a/apps/api-demo/src/layouts/Base.astro b/apps/api-demo/src/layouts/Base.astro new file mode 100644 index 000000000..cd35dbd1b --- /dev/null +++ b/apps/api-demo/src/layouts/Base.astro @@ -0,0 +1,46 @@ +--- +import ThemeInit from '../components/ThemeInit.astro'; +import { ThemeToggle } from '../components/ThemeToggle'; +import '../styles/global.css'; + +interface Props { + title: string; + description?: string; +} + +const { title, description = '' } = Astro.props; +--- + + + + + + + + {title} + {description && } + + {/* Faster video / poster loading from Mux. */} + + + + + {/* Fonts: match videojs.org (Instrument Sans + IBM Plex Mono). */} + + + + + + + +
+ +
+ + + diff --git a/apps/api-demo/src/pages/index.astro b/apps/api-demo/src/pages/index.astro new file mode 100644 index 000000000..58ddf53de --- /dev/null +++ b/apps/api-demo/src/pages/index.astro @@ -0,0 +1,48 @@ +--- +import Footer from '../components/Footer.astro'; +import PlayerDemo from '../components/PlayerDemo'; +import Base from '../layouts/Base.astro'; +--- + + +
+ + + Media API + + +

+ The Media API, Live +

+ +

+ An interactive playground for the Video.js 10 media API. Drive the + HlsJsVideo player straight through its + media instance, follow a live color-coded log of events, actions, and getter + reads, and share your setup via the URL. +

+ +
+ +
+
+