diff --git a/apps/sandbox/app/styles.css b/apps/sandbox/app/styles.css index adb0f409..33d1ce13 100644 --- a/apps/sandbox/app/styles.css +++ b/apps/sandbox/app/styles.css @@ -1,4 +1,11 @@ @import "tailwindcss"; + +/* NOTE: This is using a private export. You should import from either: +- "@videojs/html/tailwind.css" for HTML skins +- "@videojs/react/tailwind.css" for React skins +*/ +@import "@videojs/skins/shared/tailwind.css"; + @source "../app"; @source "../src"; @source "../templates"; diff --git a/internal/design/ui/input-feedback.md b/internal/design/ui/input-feedback.md new file mode 100644 index 00000000..1c2112aa --- /dev/null +++ b/internal/design/ui/input-feedback.md @@ -0,0 +1,402 @@ +--- +status: draft +date: 2026-04-24 +--- + +# Input Feedback Components + +## Context + +Standalone components for showing brief visual + accessible feedback when users trigger actions via gestures or hotkeys. The API is built from focused observer primitives: `StatusIndicator`, `StatusAnnouncer`, `VolumeIndicator`, and `SeekIndicator`. + +YouTube reference: center `role="status"` element with `aria-label="Pause"`, volume island, side seek overlays. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────┐ +│ Gesture/Hotkey Coordinators (subscribe API) │ +└──┬──────────────┬──────────────┬──────────────┬─┘ + │ │ │ │ + ┌─▼────────┐ ┌──▼────┐ ┌────────▼───┐ ┌────────▼───┐ + │ Status │ │Status │ │ Volume │ │ Seek │ + │ Indicator│ │Announ.│ │ Indicator │ │ Indicator │ + │ visual │ │ ARIA │ │ visual │ │ visual │ + └──────────┘ └───────┘ └────────────┘ └────────────┘ + ALL actions ALL volume/mute seek only + actions only +``` + +Each indicator subscribes independently to the gesture/hotkey coordinators, filters for its relevant actions, derives display state via the shared [`status.ts`](#derivation) module, and runs its own per-element transition + auto-close timer. Visual indicators also share a small per-container visibility coordinator so only one visual surface is open at a time. `StatusAnnouncer` remains independent because it is not a visual surface. + +## Labels + +StatusIndicator and StatusAnnouncer share a **single `label` string**. The indicator renders it visually; the announcer reads it for screen readers. + +Volume is the only action with a dynamic component. `label` is `"Volume"` (or `"Muted"` when muted) and `value` is always a percentage (e.g. `"0%"`, `"50%"`). The indicator renders only `value` (the icon supplies the rest); the announcer reads `label + " " + value`, or just `"Muted"`. + +| Action | Status | Label | Value | +|---|---|---|---| +| `togglePaused` (→ paused) | `pause` | `"Paused"` | — | +| `togglePaused` (→ playing) | `play` | `"Playing"` | — | +| `volumeStep` / `toggleMuted` (muted) | `volume-off` | `"Muted"` | `"0%"` | +| `volumeStep` (low) | `volume-low` | `"Volume"` | `"30%"` | +| `volumeStep` (high) | `volume-high` | `"Volume"` | `"80%"` | +| `toggleSubtitles` (on) | `captions-on` | `"Captions on"` | — | +| `toggleSubtitles` (off) | `captions-off` | `"Captions off"` | — | +| `toggleFullscreen` (→ entered) | `fullscreen` | `"Fullscreen"` | — | +| `toggleFullscreen` (→ exited) | `exit-fullscreen` | `"Exit fullscreen"` | — | +| `togglePictureInPicture` (→ entered) | `pip` | `"Picture in picture"` | — | +| `togglePictureInPicture` (→ exited) | `exit-pip` | `"Exit picture in picture"` | — | +| `seekStep` / `seekToPercent` | — | — | — | + +**Volume announcement.** Indicator renders `value` only (e.g. `"50%"`). Announcer reads `muted ? "Muted" : "Volume " + value`. + +**Seek does not announce.** SeekIndicator is visual-only; current time updates are surfaced through normal media state, not the announcer. + +## Lifecycle + +Each indicator owns its own `createTransition()` (see [`packages/core/src/dom/ui/transition.ts`](../../../packages/core/src/dom/ui/transition.ts)) and an auto-close timer. + +On a relevant action: open the transition, (re-)arm the timer; on timer fire, close the transition. Retriggers re-arm. + +Visual indicators coordinate visibility through a per-player-container `IndicatorVisibilityCoordinator`. When `StatusIndicator`, `VolumeIndicator`, or `SeekIndicator` accepts an action, it asks the coordinator to close any other registered visual indicator before showing itself. Closing another indicator uses that indicator's normal transition lifecycle, so the previous payload remains available during `data-ending-style`. React visual indicators return `null` after exit completes; HTML visual indicators keep the authored custom element as the stable host and set `hidden` after exit completes. + +This prevents overlapping visual surfaces, for example a volume island and seek bubble being visible at the same time. The coordinator does not include `StatusAnnouncer`, because announcements should still be emitted for every supported action regardless of which visual indicator is currently shown. + +The auto-close delay is exposed as a `closeDelay` prop (number, ms; default `INDICATOR_CLOSE_DELAY = 800`) — matches Tooltip/Popover's prop-driven timing convention. + +Replay on retrigger is a CSS concern: CSS transitions re-interpolate naturally; for keyframe-based entries, toggle `display: none` and use `@starting-style`. + +## Action Source + +Both `GestureCoordinator` and `HotkeyCoordinator` expose a `subscribe(callback)` method. The callback fires for every activated binding/key with a strongly-typed event: + +```ts +type InputAction = GestureActionName | HotkeyActionName; + +interface InputActionEvent { + action: InputAction; + source: 'gesture' | 'hotkey'; + event: PointerEvent | KeyboardEvent; +} + +interface InputCoordinator { + subscribe(callback: (event: InputActionEvent) => void): () => void; +} +``` + +`subscribe()` is an additional broadcast channel — bindings still dispatch their own `onActivate` as before. + +**Order.** Subscriber callbacks run **before** `onActivate` (hotkeys) or the wrapped gesture `binding.onActivate`. Input-feedback derivation therefore reads the player **`MediaSnapshot` before the action is applied**. Toggle-style rows in [`status.ts`](../../../packages/core/src/core/ui/input-feedback/status.ts) express the **next** UI state from that pre-action snapshot (e.g. `paused → !paused`). Volume stepping prediction mirrors [`volumeFeature.setVolume`](../../../packages/core/src/dom/store/features/volume.ts): mute clears when the clamped volume after the step is greater than zero. + +**Isolation.** Each subscriber invocation is wrapped in `try/catch` so one throwing observer cannot prevent media handlers from running. + +Each indicator subscribes to both coordinators on connect, filters by `action`, and unsubscribes on disconnect: + +- StatusIndicator / StatusAnnouncer: all actions in the labels table +- VolumeIndicator: `volumeStep`, `toggleMuted` +- SeekIndicator: `seekStep`, `seekToPercent` + +Preset skins use multiple filtered `StatusIndicator` instances to keep each visual surface focused: + +- Top status island: `toggleSubtitles`, `toggleFullscreen`, `togglePictureInPicture` +- Center status bubble: `togglePaused` + +Fullscreen and picture-in-picture feedback intentionally use the top status island instead of the center bubble so enter/exit actions feel like mode/status changes alongside captions, while play/pause remains the only centered status confirmation. + +## Derivation + +A pure module — [`status.ts`](../../../packages/core/src/core/ui/input-feedback/status.ts) — is the single source of truth for label/value/status rows used by StatusIndicator, StatusAnnouncer, and shared volume math. + +Exports relevant to input feedback: + +```ts +export function deriveStatus( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels?: InputIndicatorLabels +): StatusDetails | null; + +export function deriveAnnouncerLabel( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels?: InputIndicatorLabels +): string | null; + +/** Predicted mute/volume after `toggleMuted` / `volumeStep`; aligns with `volumeFeature.setVolume`. */ +export function predictVolumeActionOutcome( + event: InputActionEvent, + snapshot: MediaSnapshot +): VolumeActionPrediction; + +/** Shared by deriveStatus (volume branches) and VolumeIndicatorCore — optional cached prediction avoids duplicate work. */ +export function deriveVolumeStatus( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels?: InputIndicatorLabels, + cachedPrediction?: VolumeActionPrediction +): StatusDetails; +``` + +Seek overlays use `getSeekDirection`, `formatCurrentTime`, etc. Boundary shake (volume floor/ceiling) uses the same prediction as `deriveVolumeStatus` but min/max flags live on `VolumeIndicatorCore`, not in this module. + +## 1. StatusIndicator + +**Purely visual action confirmer.** No ARIA — StatusAnnouncer handles screen readers. + +### Responsibilities + +- Visual flash: icon + optional label for all actions +- No ARIA role — purely presentational + +### State + +```ts +type IndicatorStatus = + | 'pause' + | 'play' + | 'volume-off' + | 'volume-low' + | 'volume-high' + | 'captions-on' + | 'captions-off' + | 'fullscreen' + | 'exit-fullscreen' + | 'pip' + | 'exit-pip'; + +interface StatusIndicatorState { + open: boolean; + status: IndicatorStatus | null; + label: string | null; // static text, e.g. "Paused", "Captions on", "Fullscreen" + value: string | null; // dynamic value only, e.g. "50%" for volume +} +``` + +### Data Attributes + +``` +data-open — visible +data-status — "pause", "play", "volume-high", etc. +data-starting-style — entry transition +data-ending-style — exit transition +``` + +### Compound: Root + Value + +```html + + ... + ... + ... + + +``` + +```tsx +import { StatusIndicator } from '@videojs/react'; + + + + + {/* ... */} + + +``` + +### Props + +- `closeDelay?: number` — auto-close delay in ms. Default `800`. + +### Triggering + +- Responds to ALL actions +- Maps action + media snapshot → `IndicatorStatus` + label + +## 2. StatusAnnouncer + +**Purely ARIA — visually hidden screen reader announcements.** + +### Responsibilities + +- `role="status"` for implicit polite live-region semantics +- Sets `aria-label` to current announcement text +- Always mounted, with no transitions, animations, or skin classes +- No visual output and no text content +- Announces ALL actions for screen readers + +### State + +```ts +interface StatusAnnouncerState { + label: string | null; // shared with StatusIndicator; for volume, composed as "Volume " or "Muted" +} +``` + +### HTML + +```html + +``` + +```tsx +import { StatusAnnouncer } from '@videojs/react'; + + +``` + +- `role="status"` (implicit `aria-live="polite"`) +- `aria-label` set/cleared to announce +- Presets render the announcer outside the visual feedback overlay +- No text content — announcements use `aria-label` only + +### Props + +- `closeDelay?: number` — auto-close delay in ms. Default `800`. + +### Triggering + +- Responds to ALL actions +- Maps action + media snapshot → `aria-label` string + +## 3. VolumeIndicator + +**Readonly volume display — rich visual feedback for volume actions.** + +### Responsibilities + +- Progress fill via CSS variable (`--media-volume-fill`) +- Icon switching (off/low/high) via data attributes +- Percentage text +- Boundary data attrs (`data-min`/`data-max`) for shake animation at floor/ceiling +- No ARIA role (StatusAnnouncer handles screen readers) + +### How it differs from VolumeSlider + +| | VolumeSlider | VolumeIndicator | +|---|---|---| +| Interactive | Yes (drag, click, keyboard) | No (`pointer-events: none`) | +| Trigger | User focuses/interacts | Gesture/hotkey fires | +| Lifetime | Persistent in controls | Brief flash, auto-dismiss | +| ARIA role | `slider` | None | +| Thumb | Yes | No | + +### State + +```ts +interface VolumeIndicatorState { + open: boolean; + level: 'off' | 'low' | 'high' | null; + label: string | null; // dynamic value only, e.g. "0%", "50%" + min: boolean; // at volume floor + max: boolean; // at volume ceiling +} +``` + +### Data Attributes + +``` +data-open — visible +data-level — "off", "low", "high" +data-min — at volume floor (shake animation) +data-max — at volume ceiling (shake animation) +data-starting-style — entry transition +data-ending-style — exit transition +``` + +CSS variable: `--media-volume-fill` for gradient fill percentage. + +### Compound: Root + Value + +```html + + + ... + ... + ... + + + +``` + +```tsx +import { VolumeIndicator } from '@videojs/react'; + + + + + + + + + +``` + +### Props + +- `closeDelay?: number` — auto-close delay in ms. Default `800`. + +### Triggering + +- Filters for `volumeStep`, `toggleMuted` only + +## 4. SeekIndicator + +**Accumulating directional seek feedback.** + +### Responsibilities + +- Rapid-tap accumulation (count + total seek seconds) +- Forward/backward direction with slide-in animations +- Seek clamping (total can't exceed seekable range) +- No ARIA role (StatusAnnouncer handles screen readers) + +### State + +```ts +interface SeekIndicatorState { + open: boolean; + direction: 'forward' | 'backward' | null; + count: number; + seekTotal: number; + label: string | null; // "30s" +} +``` + +Accumulator (`count`, `seekTotal`) is local to SeekIndicator. Reset on close. + +### Data Attributes + +``` +data-open — visible +data-direction — "forward", "backward" +data-starting-style — entry transition +data-ending-style — exit transition +``` + +### Compound: Root + Value + +```html + + ... + + +``` + +```tsx +import { SeekIndicator } from '@videojs/react'; + + + + + +``` + +### Props + +- `closeDelay?: number` — auto-close delay in ms. Default `800`. + +### Triggering + +- Filters for `seekStep`, `seekToPercent` only diff --git a/package.json b/package.json index 8eb4e78c..faaa7a19 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "commit-msg": "pnpm commitlint" }, "lint-staged": { + "packages/icons/src/assets/**/*.svg": "node --import tsx packages/icons/scripts/format.ts", "*.astro": "prettier --write", "*": "biome check --write --no-errors-on-unmatched" } diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index a7176c90..e79e53d1 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -14,6 +14,16 @@ export * from './ui/error-dialog/error-dialog-core'; export * from './ui/error-dialog/error-dialog-data-attrs'; export * from './ui/fullscreen-button/fullscreen-button-core'; export * from './ui/fullscreen-button/fullscreen-button-data-attrs'; +export * from './ui/input-feedback/indicator-lifecycle'; +export * from './ui/input-feedback/seek-indicator-core'; +export * from './ui/input-feedback/seek-indicator-data-attrs'; +export * from './ui/input-feedback/status'; +export * from './ui/input-feedback/status-announcer-core'; +export * from './ui/input-feedback/status-indicator-core'; +export * from './ui/input-feedback/status-indicator-data-attrs'; +export * from './ui/input-feedback/volume-indicator-core'; +export * from './ui/input-feedback/volume-indicator-css-vars'; +export * from './ui/input-feedback/volume-indicator-data-attrs'; export * from './ui/live-button/live-button-core'; export * from './ui/live-button/live-button-data-attrs'; export * from './ui/mute-button/mute-button-core'; diff --git a/packages/core/src/core/ui/input-feedback/indicator-lifecycle.ts b/packages/core/src/core/ui/input-feedback/indicator-lifecycle.ts new file mode 100644 index 00000000..e8841591 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/indicator-lifecycle.ts @@ -0,0 +1,93 @@ +import type { TransitionFlags, TransitionState } from '../transition'; +import { getTransitionFlags } from '../transition'; + +export const INDICATOR_CLOSE_DELAY = 800; + +export interface IndicatorCoreProps { + /** Delay in milliseconds before the indicator closes. */ + closeDelay?: number | undefined; +} + +export interface IndicatorLifecycleState extends TransitionFlags { + open: boolean; + generation: number; +} + +export class IndicatorCloseController { + #timer: ReturnType | null = null; + #close: () => void; + #getDelay: () => number; + + constructor(close: () => void, getDelay: () => number) { + this.#close = close; + this.#getDelay = getDelay; + } + + arm(): void { + this.clear(); + this.#timer = setTimeout(() => { + this.#timer = null; + this.#close(); + }, this.#getDelay()); + } + + clear(): void { + if (this.#timer === null) return; + clearTimeout(this.#timer); + this.#timer = null; + } + + close(): void { + this.clear(); + this.#close(); + } + + destroy(): void { + this.clear(); + } +} + +export interface IndicatorVisibilityHandle { + close(): void; +} + +export class IndicatorVisibilityCoordinator { + #handles = new Set(); + + register(handle: Handle): () => void { + this.#handles.add(handle); + return () => this.#handles.delete(handle); + } + + show(handle: Handle): void { + for (const nextHandle of this.#handles) { + if (nextHandle !== handle) nextHandle.close(); + } + } +} + +export function getIndicatorCloseDelay(props: IndicatorCoreProps): number { + return props.closeDelay ?? INDICATOR_CLOSE_DELAY; +} + +export function isIndicatorPresent( + current: Pick, + transition: Pick +): boolean { + return current.open || transition.active; +} + +export function getRenderedIndicatorState( + current: State, + snapshot: State, + transition: TransitionState +): State { + const payload = current.open ? current : snapshot; + + return { + ...payload, + open: current.open && transition.active, + generation: current.open ? current.generation : payload.generation, + ...getTransitionFlags(transition.status), + }; +} diff --git a/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts b/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts new file mode 100644 index 00000000..a4f841b6 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts @@ -0,0 +1,112 @@ +import { createState } from '@videojs/store'; + +import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; +import { + formatCurrentTime, + getSeekDirection, + type IndicatorDirection, + type InputActionEvent, + isSeekIndicatorAction, + type MediaSnapshot, +} from './status'; + +export interface SeekIndicatorProps extends IndicatorCoreProps {} + +export interface SeekIndicatorState extends IndicatorLifecycleState { + direction: IndicatorDirection | null; + count: number; + seekTotal: number; + value: string | null; + currentTime: string; +} + +const INITIAL_STATE: SeekIndicatorState = { + open: false, + generation: 0, + direction: null, + count: 0, + seekTotal: 0, + value: null, + currentTime: '0:00', + transitionStarting: false, + transitionEnding: false, +}; + +export class SeekIndicatorCore { + readonly state = createState({ ...INITIAL_STATE }); + + #props: SeekIndicatorProps = {}; + #originTime: number | null = null; + #close = new IndicatorCloseController( + () => { + this.#originTime = null; + this.state.patch({ + open: false, + direction: null, + count: 0, + seekTotal: 0, + value: null, + }); + }, + () => getIndicatorCloseDelay(this.#props) + ); + + setProps(props: SeekIndicatorProps): void { + this.#props = props; + } + + destroy(): void { + this.#close.destroy(); + } + + close(): void { + this.#close.close(); + } + + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean { + if (!isSeekIndicatorAction(event.action)) return false; + + const current = this.state.current; + const direction = getSeekDirection(event, snapshot); + const rapidRepeat = current.open && event.action === 'seekStep' && current.direction === direction; + + if (!rapidRepeat) { + this.#originTime = snapshot.currentTime ?? null; + } + + const value = this.#getEffectiveSeekValue(event, snapshot, rapidRepeat); + const seekTotal = rapidRepeat ? current.seekTotal + Math.abs(value) : Math.abs(value); + + this.state.patch({ + open: true, + generation: current.generation + 1, + direction, + count: rapidRepeat ? current.count + 1 : 1, + seekTotal, + value: event.action === 'seekStep' && seekTotal > 0 ? `${seekTotal}s` : null, + currentTime: formatCurrentTime(snapshot), + }); + this.#close.arm(); + return true; + } + + #getEffectiveSeekValue(event: InputActionEvent, snapshot: MediaSnapshot, rapidRepeat: boolean): number { + if (event.action !== 'seekStep' || event.value === undefined) return 0; + if (!rapidRepeat || this.#originTime === null) return event.value; + + const originTime = this.#originTime; + const duration = snapshot.duration ?? Infinity; + const currentTotal = this.state.current.seekTotal; + const step = Math.abs(event.value); + const room = + event.value < 0 ? Math.max(0, originTime - currentTotal) : Math.max(0, duration - originTime - currentTotal); + + return room >= step ? event.value : 0; + } +} + +export namespace SeekIndicatorCore { + export type Props = SeekIndicatorProps; + export type State = SeekIndicatorState; +} diff --git a/packages/core/src/core/ui/input-feedback/seek-indicator-data-attrs.ts b/packages/core/src/core/ui/input-feedback/seek-indicator-data-attrs.ts new file mode 100644 index 00000000..36bf920b --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/seek-indicator-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { SeekIndicatorState } from './seek-indicator-core'; + +export const SeekIndicatorDataAttrs = { + open: 'data-open', + direction: 'data-direction', + transitionStarting: 'data-starting-style', + transitionEnding: 'data-ending-style', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts new file mode 100644 index 00000000..e7f042b7 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -0,0 +1,54 @@ +import { createState } from '@videojs/store'; + +import type { IndicatorCoreProps } from './indicator-lifecycle'; +import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; +import { + DEFAULT_INPUT_INDICATOR_LABELS, + deriveAnnouncerLabel, + type InputActionEvent, + type InputIndicatorLabels, + type MediaSnapshot, +} from './status'; + +export interface StatusAnnouncerProps extends IndicatorCoreProps { + labels?: Partial | undefined; +} + +export interface StatusAnnouncerState { + label: string | null; +} + +export class StatusAnnouncerCore { + readonly state = createState({ label: null }); + + #props: StatusAnnouncerProps = {}; + #close = new IndicatorCloseController( + () => this.state.patch({ label: null }), + () => getIndicatorCloseDelay(this.#props) + ); + + setProps(props: StatusAnnouncerProps): void { + this.#props = props; + } + + destroy(): void { + this.#close.destroy(); + } + + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean { + const label = deriveAnnouncerLabel(event, snapshot, { + ...DEFAULT_INPUT_INDICATOR_LABELS, + ...this.#props.labels, + }); + if (!label) return false; + + this.state.patch({ label }); + this.#close.arm(); + return true; + } +} + +export namespace StatusAnnouncerCore { + export type Props = StatusAnnouncerProps; + export type State = StatusAnnouncerState; +} diff --git a/packages/core/src/core/ui/input-feedback/status-indicator-core.ts b/packages/core/src/core/ui/input-feedback/status-indicator-core.ts new file mode 100644 index 00000000..cc3c7f08 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/status-indicator-core.ts @@ -0,0 +1,85 @@ +import { createState } from '@videojs/store'; + +import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; +import { + DEFAULT_INPUT_INDICATOR_LABELS, + deriveStatus, + type InputAction, + type InputActionEvent, + type InputIndicatorLabels, + isInputActionIncluded, + type MediaSnapshot, +} from './status'; + +export interface StatusIndicatorProps extends IndicatorCoreProps { + actions?: readonly InputAction[] | undefined; + labels?: Partial | undefined; +} + +export interface StatusIndicatorState extends IndicatorLifecycleState { + status: ReturnType extends infer Details + ? Details extends { status: infer Status } + ? Status | null + : never + : never; + label: string | null; + value: string | null; +} + +const INITIAL_STATE: StatusIndicatorState = { + open: false, + generation: 0, + status: null, + label: null, + value: null, + transitionStarting: false, + transitionEnding: false, +}; + +export class StatusIndicatorCore { + readonly state = createState({ ...INITIAL_STATE }); + + #props: StatusIndicatorProps = {}; + #close = new IndicatorCloseController( + () => this.state.patch({ open: false, status: null, label: null, value: null }), + () => getIndicatorCloseDelay(this.#props) + ); + + setProps(props: StatusIndicatorProps): void { + this.#props = props; + } + + destroy(): void { + this.#close.destroy(); + } + + close(): void { + this.#close.close(); + } + + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean { + if (!isInputActionIncluded(event.action, this.#props.actions)) return false; + + const details = deriveStatus(event, snapshot, { + ...DEFAULT_INPUT_INDICATOR_LABELS, + ...this.#props.labels, + }); + if (!details) return false; + + this.state.patch({ + open: true, + generation: this.state.current.generation + 1, + status: details.status, + label: details.label, + value: details.value, + }); + this.#close.arm(); + return true; + } +} + +export namespace StatusIndicatorCore { + export type Props = StatusIndicatorProps; + export type State = StatusIndicatorState; +} diff --git a/packages/core/src/core/ui/input-feedback/status-indicator-data-attrs.ts b/packages/core/src/core/ui/input-feedback/status-indicator-data-attrs.ts new file mode 100644 index 00000000..60a03416 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/status-indicator-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { StatusIndicatorState } from './status-indicator-core'; + +export const StatusIndicatorDataAttrs = { + open: 'data-open', + status: 'data-status', + transitionStarting: 'data-starting-style', + transitionEnding: 'data-ending-style', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/input-feedback/status.ts b/packages/core/src/core/ui/input-feedback/status.ts new file mode 100644 index 00000000..046b9065 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/status.ts @@ -0,0 +1,267 @@ +import { clamp } from '@videojs/utils/number'; +import { formatTime } from '@videojs/utils/time'; + +export type InputActionSource = 'gesture' | 'hotkey'; + +export type InputAction = + | 'togglePaused' + | 'toggleMuted' + | 'toggleFullscreen' + | 'toggleSubtitles' + | 'togglePictureInPicture' + | 'toggleControls' + | 'seekStep' + | 'seekToPercent' + | 'volumeStep' + | 'speedUp' + | 'speedDown' + | (string & {}); + +export type IndicatorDirection = 'forward' | 'backward'; +export type IndicatorVolumeLevel = 'off' | 'low' | 'high'; + +export type IndicatorStatus = + | 'pause' + | 'play' + | 'volume-off' + | 'volume-low' + | 'volume-high' + | 'captions-on' + | 'captions-off' + | 'fullscreen' + | 'exit-fullscreen' + | 'pip' + | 'exit-pip'; + +export interface InputActionEvent { + action?: string | undefined; + value?: number | undefined; + source?: InputActionSource | undefined; + key?: string | undefined; +} + +export interface MediaSnapshot { + paused?: boolean | undefined; + volume?: number | undefined; + muted?: boolean | undefined; + fullscreen?: boolean | undefined; + subtitlesShowing?: boolean | undefined; + pip?: boolean | undefined; + currentTime?: number | undefined; + duration?: number | undefined; +} + +export interface InputIndicatorLabels { + muted: string; + volume: string; + captionsOn: string; + captionsOff: string; + paused: string; + playing: string; + fullscreen: string; + exitFullscreen: string; + pictureInPicture: string; + exitPictureInPicture: string; +} + +export interface StatusDetails { + status: IndicatorStatus; + label: string; + value: string | null; + volumeLevel: IndicatorVolumeLevel | null; +} + +export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = { + muted: 'Muted', + volume: 'Volume', + captionsOn: 'Captions on', + captionsOff: 'Captions off', + paused: 'Paused', + playing: 'Playing', + fullscreen: 'Fullscreen', + exitFullscreen: 'Exit fullscreen', + pictureInPicture: 'Picture in picture', + exitPictureInPicture: 'Exit picture in picture', +}; + +export function isVolumeIndicatorAction(action: string | null | undefined): action is 'toggleMuted' | 'volumeStep' { + return action === 'toggleMuted' || action === 'volumeStep'; +} + +export function isSeekIndicatorAction(action: string | null | undefined): action is 'seekStep' | 'seekToPercent' { + return action === 'seekStep' || action === 'seekToPercent'; +} + +export function deriveStatus( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS +): StatusDetails | null { + switch (event.action) { + case 'togglePaused': { + const paused = snapshot.paused !== undefined ? !snapshot.paused : true; + return { + status: paused ? 'pause' : 'play', + label: paused ? labels.paused : labels.playing, + value: null, + volumeLevel: null, + }; + } + case 'toggleMuted': + case 'volumeStep': + return deriveVolumeStatus(event, snapshot, labels); + case 'toggleSubtitles': { + const showing = snapshot.subtitlesShowing !== undefined ? !snapshot.subtitlesShowing : true; + return { + status: showing ? 'captions-on' : 'captions-off', + label: showing ? labels.captionsOn : labels.captionsOff, + value: null, + volumeLevel: null, + }; + } + case 'toggleFullscreen': { + const fullscreen = snapshot.fullscreen !== undefined ? !snapshot.fullscreen : true; + return { + status: fullscreen ? 'fullscreen' : 'exit-fullscreen', + label: fullscreen ? labels.fullscreen : labels.exitFullscreen, + value: null, + volumeLevel: null, + }; + } + case 'togglePictureInPicture': { + const pip = snapshot.pip !== undefined ? !snapshot.pip : true; + return { + status: pip ? 'pip' : 'exit-pip', + label: pip ? labels.pictureInPicture : labels.exitPictureInPicture, + value: null, + volumeLevel: null, + }; + } + default: + return null; + } +} + +export function deriveAnnouncerLabel( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS +): string | null { + const details = deriveStatus(event, snapshot, labels); + if (!details) return null; + + if (isVolumeIndicatorAction(event.action)) { + return details.status === 'volume-off' ? labels.muted : `${labels.volume} ${details.value}`; + } + + return details.label; +} + +export function getVolumeLevel(volume: number): IndicatorVolumeLevel { + if (volume <= 0) return 'off'; + return volume <= 0.5 ? 'low' : 'high'; +} + +export function formatVolumeValue(volume: number): string { + return `${Math.round(clamp(volume, 0, 1) * 100)}%`; +} + +export function formatCurrentTime(snapshot: MediaSnapshot): string { + return formatTime(snapshot.currentTime ?? 0, snapshot.duration); +} + +export function getStatusIndicatorDisplayValue(state: { value: string | null; label: string | null }): string { + return state.value ?? state.label ?? ''; +} + +export function getVolumeIndicatorDisplayValue(state: { value: string | null }): string { + return state.value ?? ''; +} + +export function getSeekIndicatorDisplayValue(state: { value: string | null; currentTime: string }): string { + return state.value ?? state.currentTime; +} + +export function getSeekToPercent(event: InputActionEvent): number | null { + if (event.value !== undefined) return clamp(event.value, 0, 100); + if (!event.key || event.key < '0' || event.key > '9') return null; + return Number(event.key) * 10; +} + +export function getSeekDirection(event: InputActionEvent, snapshot: MediaSnapshot): IndicatorDirection | null { + if (event.action === 'seekStep' && event.value !== undefined) { + if (event.value > 0) return 'forward'; + if (event.value < 0) return 'backward'; + } + + if (event.action === 'seekToPercent') { + const percent = getSeekToPercent(event); + if (percent === null || snapshot.duration === undefined || snapshot.duration <= 0) return null; + + const targetTime = (percent / 100) * snapshot.duration; + const currentTime = snapshot.currentTime ?? 0; + if (targetTime > currentTime) return 'forward'; + if (targetTime < currentTime) return 'backward'; + } + + return null; +} + +export function isInputActionIncluded( + action: string | undefined, + actions: readonly InputAction[] | undefined +): boolean { + if (!action) return false; + return !actions || actions.includes(action); +} + +/** Predicted mute/volume after a volume-indicator action — shared by status derivation and boundary detection. */ +export interface VolumeActionPrediction { + snapshotVolume: number; + nextMuted: boolean; + nextVolume: number; +} + +export function predictVolumeActionOutcome(event: InputActionEvent, snapshot: MediaSnapshot): VolumeActionPrediction { + const muted = snapshot.muted === true; + const snapshotVolume = snapshot.volume ?? 0; + + if (event.action === 'toggleMuted') { + return { snapshotVolume, nextMuted: !muted, nextVolume: snapshotVolume }; + } + + if (event.action === 'volumeStep') { + const nextVolume = clamp(snapshotVolume + (event.value ?? 0), 0, 1); + /** Mirrors `volumeFeature.setVolume`: mute clears only when the clamped volume is greater than 0. */ + const nextMuted = muted && nextVolume <= 0; + return { snapshotVolume, nextMuted, nextVolume }; + } + + return { snapshotVolume, nextMuted: muted, nextVolume: snapshotVolume }; +} + +function volumePredictionToStatusDetails( + prediction: VolumeActionPrediction, + labels: InputIndicatorLabels +): StatusDetails { + const level = prediction.nextMuted ? 'off' : getVolumeLevel(prediction.nextVolume); + const value = prediction.nextMuted ? '0%' : formatVolumeValue(prediction.nextVolume); + + return { + status: level === 'off' ? 'volume-off' : level === 'low' ? 'volume-low' : 'volume-high', + label: level === 'off' ? labels.muted : labels.volume, + value, + volumeLevel: level, + }; +} + +/** Labels/value/level for volume actions — single source shared with `VolumeIndicatorCore`. */ +export function deriveVolumeStatus( + event: InputActionEvent, + snapshot: MediaSnapshot, + labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS, + cachedPrediction?: VolumeActionPrediction +): StatusDetails { + const prediction = cachedPrediction ?? predictVolumeActionOutcome(event, snapshot); + return volumePredictionToStatusDetails(prediction, labels); +} diff --git a/packages/core/src/core/ui/input-feedback/tests/indicator-lifecycle.test.ts b/packages/core/src/core/ui/input-feedback/tests/indicator-lifecycle.test.ts new file mode 100644 index 00000000..0400960a --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/tests/indicator-lifecycle.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + getRenderedIndicatorState, + type IndicatorLifecycleState, + IndicatorVisibilityCoordinator, + isIndicatorPresent, +} from '../indicator-lifecycle'; + +interface TestState extends IndicatorLifecycleState { + value: string | null; +} + +const IDLE_STATE: TestState = { + open: false, + generation: 0, + value: null, + transitionStarting: false, + transitionEnding: false, +}; + +describe('indicator-lifecycle', () => { + it('keeps the snapshot payload while an indicator transitions out', () => { + const snapshot: TestState = { + ...IDLE_STATE, + open: true, + generation: 1, + value: 'Paused', + }; + + const rendered = getRenderedIndicatorState(IDLE_STATE, snapshot, { + active: true, + status: 'ending', + }); + + expect(rendered.open).toBe(false); + expect(rendered.value).toBe('Paused'); + expect(rendered.transitionEnding).toBe(true); + }); + + it('stays present until both logical state and transition are inactive', () => { + expect(isIndicatorPresent(IDLE_STATE, { active: true })).toBe(true); + expect(isIndicatorPresent(IDLE_STATE, { active: false })).toBe(false); + }); + + it('closes registered indicators when another indicator is shown', () => { + const coordinator = new IndicatorVisibilityCoordinator(); + const first = { close: vi.fn() }; + const second = { close: vi.fn() }; + + coordinator.register(first); + coordinator.register(second); + coordinator.show(second); + + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/core/ui/input-feedback/tests/seek-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/seek-indicator-core.test.ts new file mode 100644 index 00000000..fe3fd5b2 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/tests/seek-indicator-core.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SeekIndicatorCore } from '../seek-indicator-core'; + +describe('SeekIndicatorCore', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('accumulates rapid seek steps in the same direction', () => { + const core = new SeekIndicatorCore(); + + core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 }); + core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 }); + + expect(core.state.current.count).toBe(2); + expect(core.state.current.seekTotal).toBe(20); + expect(core.state.current.value).toBe('20s'); + }); + + it('clamps accumulated seek steps to the available media range', () => { + const core = new SeekIndicatorCore(); + const snapshot = { currentTime: 115, duration: 120 }; + + core.processEvent({ action: 'seekStep', value: 10 }, snapshot); + core.processEvent({ action: 'seekStep', value: 10 }, snapshot); + + expect(core.state.current.seekTotal).toBe(10); + }); + + it('infers seek-to-percent direction and always keeps current-time text', () => { + const core = new SeekIndicatorCore(); + + core.processEvent({ action: 'seekToPercent', key: '8' }, { currentTime: 30, duration: 120 }); + + expect(core.state.current.direction).toBe('forward'); + expect(core.state.current.value).toBeNull(); + expect(core.state.current.currentTime).toBe('0:30'); + }); + + it('closes and resets accumulation after the configured delay', () => { + const core = new SeekIndicatorCore(); + core.setProps({ closeDelay: 100 }); + core.processEvent({ action: 'seekStep', value: -10 }, { currentTime: 30, duration: 120 }); + + vi.advanceTimersByTime(100); + + expect(core.state.current.open).toBe(false); + expect(core.state.current.count).toBe(0); + }); +}); diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts new file mode 100644 index 00000000..c74a6788 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StatusAnnouncerCore } from '../status-announcer-core'; +import { StatusIndicatorCore } from '../status-indicator-core'; + +describe('StatusIndicatorCore', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('honors the optional action filter', () => { + const core = new StatusIndicatorCore(); + core.setProps({ actions: ['toggleSubtitles'] }); + + expect(core.processEvent({ action: 'togglePaused' }, { paused: false })).toBe(false); + expect(core.processEvent({ action: 'toggleSubtitles' }, { subtitlesShowing: false })).toBe(true); + expect(core.state.current.status).toBe('captions-on'); + }); + + it('increments generation on each accepted trigger', () => { + const core = new StatusIndicatorCore(); + + core.processEvent({ action: 'togglePaused' }, { paused: false }); + core.processEvent({ action: 'togglePaused' }, { paused: false }); + + expect(core.state.current.generation).toBe(2); + }); + + it('clears after the configured delay', () => { + const core = new StatusIndicatorCore(); + core.setProps({ closeDelay: 100 }); + core.processEvent({ action: 'toggleFullscreen' }, { fullscreen: false }); + + vi.advanceTimersByTime(100); + + expect(core.state.current.open).toBe(false); + expect(core.state.current.status).toBeNull(); + }); +}); + +describe('StatusAnnouncerCore', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('announces status labels and clears them after the delay', () => { + const core = new StatusAnnouncerCore(); + core.setProps({ closeDelay: 100 }); + + expect(core.processEvent({ action: 'volumeStep', value: 0.1 }, { volume: 0.5, muted: false })).toBe(true); + expect(core.state.current.label).toBe('Volume 60%'); + + vi.advanceTimersByTime(100); + expect(core.state.current.label).toBeNull(); + }); +}); diff --git a/packages/core/src/core/ui/input-feedback/tests/status.test.ts b/packages/core/src/core/ui/input-feedback/tests/status.test.ts new file mode 100644 index 00000000..1d72f6b2 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/tests/status.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveAnnouncerLabel, + deriveStatus, + getSeekDirection, + getSeekIndicatorDisplayValue, + getStatusIndicatorDisplayValue, + getVolumeIndicatorDisplayValue, + type MediaSnapshot, + predictVolumeActionOutcome, +} from '../status'; + +const SNAPSHOT: MediaSnapshot = { + paused: false, + volume: 0.5, + muted: false, + fullscreen: false, + subtitlesShowing: false, + pip: false, + currentTime: 30, + duration: 120, +}; + +describe('status', () => { + it('derives playback status from the expected next state', () => { + expect(deriveStatus({ action: 'togglePaused' }, SNAPSHOT)).toMatchObject({ + status: 'pause', + label: 'Paused', + }); + + expect(deriveStatus({ action: 'togglePaused' }, { ...SNAPSHOT, paused: true })).toMatchObject({ + status: 'play', + label: 'Playing', + }); + }); + + it('derives volume status, value, and announcer labels', () => { + expect(deriveStatus({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toMatchObject({ + status: 'volume-high', + label: 'Volume', + value: '80%', + volumeLevel: 'high', + }); + + expect(deriveAnnouncerLabel({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toBe('Volume 80%'); + expect(deriveAnnouncerLabel({ action: 'toggleMuted' }, SNAPSHOT)).toBe('Muted'); + }); + + it('derives captions, fullscreen, and picture-in-picture statuses', () => { + expect(deriveStatus({ action: 'toggleSubtitles' }, SNAPSHOT)?.status).toBe('captions-on'); + expect(deriveStatus({ action: 'toggleFullscreen' }, SNAPSHOT)?.status).toBe('fullscreen'); + expect(deriveStatus({ action: 'toggleFullscreen' }, { ...SNAPSHOT, fullscreen: true })?.status).toBe( + 'exit-fullscreen' + ); + expect(deriveStatus({ action: 'togglePictureInPicture' }, SNAPSHOT)?.status).toBe('pip'); + expect(deriveStatus({ action: 'togglePictureInPicture' }, { ...SNAPSHOT, pip: true })?.status).toBe('exit-pip'); + }); + + it('does not derive status or values for seek and unsupported actions', () => { + expect(deriveStatus({ action: 'seekStep', value: 10 }, SNAPSHOT)).toBeNull(); + expect(deriveStatus({ action: 'seekToPercent', value: 50 }, SNAPSHOT)?.value ?? null).toBeNull(); + expect(deriveStatus({ action: 'speedUp' }, SNAPSHOT)).toBeNull(); + }); + + it('predicts volume outcome like volumeFeature.setVolume when muted', () => { + expect(predictVolumeActionOutcome({ action: 'volumeStep', value: 0.05 }, { muted: true, volume: 0.5 })).toEqual({ + snapshotVolume: 0.5, + nextMuted: false, + nextVolume: 0.55, + }); + + expect(predictVolumeActionOutcome({ action: 'volumeStep', value: -0.05 }, { muted: true, volume: 0.05 })).toEqual({ + snapshotVolume: 0.05, + nextMuted: true, + nextVolume: 0, + }); + }); + + it('infers seek direction from action details', () => { + expect(getSeekDirection({ action: 'seekStep', value: -10 }, SNAPSHOT)).toBe('backward'); + expect(getSeekDirection({ action: 'seekToPercent', key: '8' }, SNAPSHOT)).toBe('forward'); + }); + + it('derives display values for mounted indicators', () => { + expect(getStatusIndicatorDisplayValue({ label: 'Paused', value: null })).toBe('Paused'); + expect(getVolumeIndicatorDisplayValue({ value: null })).toBe(''); + expect(getSeekIndicatorDisplayValue({ value: null, currentTime: '0:30' })).toBe('0:30'); + }); +}); diff --git a/packages/core/src/core/ui/input-feedback/tests/volume-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/volume-indicator-core.test.ts new file mode 100644 index 00000000..4c19bd24 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/tests/volume-indicator-core.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VolumeIndicatorCore } from '../volume-indicator-core'; + +describe('VolumeIndicatorCore', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('opens with scoped volume state for volume actions only', () => { + const core = new VolumeIndicatorCore(); + + expect(core.processEvent({ action: 'togglePaused' }, { volume: 0.5, muted: false })).toBe(false); + expect(core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false })).toBe(true); + expect(core.state.current.open).toBe(true); + expect(core.state.current.level).toBe('high'); + expect(core.state.current.value).toBe('55%'); + expect(core.state.current.fill).toBe('55%'); + }); + + it('accumulates volume steps from sequential media snapshots', () => { + const core = new VolumeIndicatorCore(); + + core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false }); + core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.55, muted: false }); + + expect(core.state.current.value).toBe('60%'); + }); + + it('uses snapshot volume after mute feedback showed 0%', () => { + const core = new VolumeIndicatorCore(); + + core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false }); + expect(core.state.current.value).toBe('0%'); + + core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: true }); + expect(core.state.current.value).toBe('55%'); + }); + + it('does not treat a zero volume step as a min/max boundary hit', () => { + const core = new VolumeIndicatorCore(); + + core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 1, muted: false }); + expect(core.state.current.max).toBe(false); + expect(core.state.current.min).toBe(false); + + core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 0, muted: false }); + expect(core.state.current.max).toBe(false); + expect(core.state.current.min).toBe(false); + }); + + it('restarts the boundary flag when the same edge is hit repeatedly', () => { + const core = new VolumeIndicatorCore(); + + core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false }); + expect(core.state.current.max).toBe(true); + + core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false }); + expect(core.state.current.max).toBe(false); + + vi.advanceTimersByTime(0); + expect(core.state.current.max).toBe(true); + + vi.advanceTimersByTime(300); + expect(core.state.current.max).toBe(false); + }); + + it('closes after the configured delay', () => { + const core = new VolumeIndicatorCore(); + core.setProps({ closeDelay: 100 }); + core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false }); + + vi.advanceTimersByTime(100); + + expect(core.state.current.open).toBe(false); + expect(core.state.current.value).toBeNull(); + }); +}); diff --git a/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts new file mode 100644 index 00000000..e43e1ffb --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts @@ -0,0 +1,142 @@ +import { createState } from '@videojs/store'; + +import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; +import { + DEFAULT_INPUT_INDICATOR_LABELS, + deriveVolumeStatus, + type IndicatorVolumeLevel, + type InputActionEvent, + isVolumeIndicatorAction, + type MediaSnapshot, + predictVolumeActionOutcome, +} from './status'; + +export interface VolumeIndicatorProps extends IndicatorCoreProps {} + +export interface VolumeIndicatorState extends IndicatorLifecycleState { + level: IndicatorVolumeLevel | null; + value: string | null; + fill: string | null; + min: boolean; + max: boolean; +} + +const BOUNDARY_CLEAR_DELAY = 300; + +const INITIAL_STATE: VolumeIndicatorState = { + open: false, + generation: 0, + level: null, + value: null, + fill: null, + min: false, + max: false, + transitionStarting: false, + transitionEnding: false, +}; + +export class VolumeIndicatorCore { + readonly state = createState({ ...INITIAL_STATE }); + + #props: VolumeIndicatorProps = {}; + #boundaryTimer: ReturnType | null = null; + #boundaryRestartTimer: ReturnType | null = null; + #close = new IndicatorCloseController( + () => this.state.patch({ open: false, level: null, value: null, fill: null, min: false, max: false }), + () => getIndicatorCloseDelay(this.#props) + ); + + setProps(props: VolumeIndicatorProps): void { + this.#props = props; + } + + destroy(): void { + this.#close.destroy(); + this.#clearBoundaryTimers(); + } + + close(): void { + this.#clearBoundaryTimers(); + this.#close.close(); + } + + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean { + if (!isVolumeIndicatorAction(event.action)) return false; + + const current = this.state.current; + const prediction = predictVolumeActionOutcome(event, snapshot); + const details = deriveVolumeStatus(event, snapshot, DEFAULT_INPUT_INDICATOR_LABELS, prediction); + const boundary = getVolumeBoundary(event, prediction.snapshotVolume, prediction.nextVolume); + const repeatedBoundary = boundary !== null && current[boundary] === true; + + if (!boundary) this.#clearBoundaryTimers(); + + this.state.patch({ + open: true, + generation: current.generation + 1, + level: details.volumeLevel, + value: details.value, + fill: details.value, + min: boundary === 'min' && !repeatedBoundary, + max: boundary === 'max' && !repeatedBoundary, + }); + + if (boundary) { + if (repeatedBoundary) { + this.#restartBoundary(boundary); + } else { + this.#scheduleBoundaryClear(); + } + } + + this.#close.arm(); + return true; + } + + #scheduleBoundaryClear(): void { + this.#clearBoundaryTimer(); + this.#boundaryTimer = setTimeout(() => { + this.#boundaryTimer = null; + this.state.patch({ min: false, max: false }); + }, BOUNDARY_CLEAR_DELAY); + } + + #restartBoundary(boundary: 'min' | 'max'): void { + this.#clearBoundaryTimers(); + this.state.patch({ min: false, max: false }); + this.#boundaryRestartTimer = setTimeout(() => { + this.#boundaryRestartTimer = null; + this.state.patch({ [boundary]: true }); + this.#scheduleBoundaryClear(); + }, 0); + } + + #clearBoundaryTimer(): void { + if (this.#boundaryTimer === null) return; + clearTimeout(this.#boundaryTimer); + this.#boundaryTimer = null; + } + + #clearBoundaryRestartTimer(): void { + if (this.#boundaryRestartTimer === null) return; + clearTimeout(this.#boundaryRestartTimer); + this.#boundaryRestartTimer = null; + } + + #clearBoundaryTimers(): void { + this.#clearBoundaryTimer(); + this.#clearBoundaryRestartTimer(); + } +} + +export namespace VolumeIndicatorCore { + export type Props = VolumeIndicatorProps; + export type State = VolumeIndicatorState; +} + +function getVolumeBoundary(event: InputActionEvent, currentVolume: number, nextVolume: number): 'min' | 'max' | null { + if (event.action !== 'volumeStep' || event.value === undefined || event.value === 0) return null; + if (nextVolume !== currentVolume) return null; + return event.value < 0 ? 'min' : 'max'; +} diff --git a/packages/core/src/core/ui/input-feedback/volume-indicator-css-vars.ts b/packages/core/src/core/ui/input-feedback/volume-indicator-css-vars.ts new file mode 100644 index 00000000..3311e751 --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/volume-indicator-css-vars.ts @@ -0,0 +1,3 @@ +export const VolumeIndicatorCSSVars = { + fill: '--media-volume-fill', +} as const; diff --git a/packages/core/src/core/ui/input-feedback/volume-indicator-data-attrs.ts b/packages/core/src/core/ui/input-feedback/volume-indicator-data-attrs.ts new file mode 100644 index 00000000..4dfedccf --- /dev/null +++ b/packages/core/src/core/ui/input-feedback/volume-indicator-data-attrs.ts @@ -0,0 +1,11 @@ +import type { StateAttrMap } from '../types'; +import type { VolumeIndicatorState } from './volume-indicator-core'; + +export const VolumeIndicatorDataAttrs = { + open: 'data-open', + level: 'data-level', + min: 'data-min', + max: 'data-max', + transitionStarting: 'data-starting-style', + transitionEnding: 'data-ending-style', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/dom/gesture/actions.ts b/packages/core/src/dom/gesture/actions.ts index 7a2811e4..937d2410 100644 --- a/packages/core/src/dom/gesture/actions.ts +++ b/packages/core/src/dom/gesture/actions.ts @@ -1,7 +1,6 @@ -import { isFunction, isUndefined } from '@videojs/utils/predicate'; - +import { isFunction } from '@videojs/utils/predicate'; import type { AnyPlayerStore } from '../media/types'; -import { selectPlaybackRate, selectTime, selectVolume } from '../store/selectors'; +import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions'; export type GestureActionName = | 'togglePaused' @@ -25,37 +24,13 @@ export type GestureActionResolver = (context: GestureActionContext) => void; /** Actions that need custom logic beyond `store.state[action]()`. */ const GESTURE_ACTION_OVERRIDES: Partial> = { - seekStep({ store, value }) { - if (isUndefined(value)) return; - const time = selectTime(store.state); - if (!time) return; - time.seek(time.currentTime + value); - }, + seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep, - volumeStep({ store, value }) { - if (isUndefined(value)) return; - const vol = selectVolume(store.state); - if (!vol) return; - vol.setVolume(vol.volume + value); - }, + volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep, - speedUp({ store }) { - const rate = selectPlaybackRate(store.state); - if (!rate) return; - const { playbackRates, playbackRate } = rate; - const idx = playbackRates.indexOf(playbackRate); - const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1; - rate.setPlaybackRate(playbackRates[next]!); - }, + speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp, - speedDown({ store }) { - const rate = selectPlaybackRate(store.state); - if (!rate) return; - const { playbackRates, playbackRate } = rate; - const idx = playbackRates.indexOf(playbackRate); - const next = idx <= 0 ? playbackRates.length - 1 : idx - 1; - rate.setPlaybackRate(playbackRates[next]!); - }, + speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown, }; export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined { diff --git a/packages/core/src/dom/gesture/coordinator.ts b/packages/core/src/dom/gesture/coordinator.ts index ae34291e..5d3fb41d 100644 --- a/packages/core/src/dom/gesture/coordinator.ts +++ b/packages/core/src/dom/gesture/coordinator.ts @@ -1,6 +1,13 @@ import { isInteractiveTarget, listen } from '@videojs/utils/dom'; -import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture'; +import type { + GestureActivateEvent, + GestureBinding, + GestureMatchResult, + GestureRecognizer, + GestureRegion, + GestureType, +} from './gesture'; import { resolveRegion } from './region'; const TAP_THRESHOLD = 250; @@ -10,6 +17,7 @@ export class GestureCoordinator { #bindings: GestureBinding[] = []; #recognizers = new Set(); #disconnect: AbortController | null = null; + #subscribers = new Set<(event: GestureActivateEvent) => void>(); constructor(target: HTMLElement) { this.#target = target; @@ -19,9 +27,39 @@ export class GestureCoordinator { return this.#bindings; } + subscribe(callback: (event: GestureActivateEvent) => void): () => void { + this.#subscribers.add(callback); + return () => this.#subscribers.delete(callback); + } + add(binding: GestureBinding): () => void { - this.#bindings.push(binding); - this.#recognizers.add(binding.recognizer); + const wrapped: GestureBinding = { + ...binding, + onActivate: (event) => { + if (this.#subscribers.size > 0) { + const activateEvent: GestureActivateEvent = { + type: binding.type, + source: 'gesture', + action: binding.action, + value: binding.value, + region: binding.region, + pointer: binding.pointer, + event, + }; + for (const cb of this.#subscribers) { + try { + cb(activateEvent); + } catch (error) { + if (__DEV__) console.warn('[vjs-gesture] subscribe callback threw:', error); + } + } + } + binding.onActivate(event); + }, + }; + + this.#bindings.push(wrapped); + this.#recognizers.add(wrapped.recognizer); this.#connect(); let removed = false; @@ -29,7 +67,7 @@ export class GestureCoordinator { if (removed) return; removed = true; - const idx = this.#bindings.indexOf(binding); + const idx = this.#bindings.indexOf(wrapped); if (idx !== -1) this.#bindings.splice(idx, 1); this.#maybeDisconnect(); diff --git a/packages/core/src/dom/gesture/create-tap-gesture.ts b/packages/core/src/dom/gesture/create-tap-gesture.ts index 2ea9235a..6187c1e1 100644 --- a/packages/core/src/dom/gesture/create-tap-gesture.ts +++ b/packages/core/src/dom/gesture/create-tap-gesture.ts @@ -36,6 +36,7 @@ export function createTapGesture( region: options?.region, disabled: options?.disabled, action: options?.action, + value: options?.value, }); } @@ -62,5 +63,6 @@ export function createDoubleTapGesture( region: options?.region, disabled: options?.disabled, action: options?.action, + value: options?.value, }); } diff --git a/packages/core/src/dom/gesture/gesture.ts b/packages/core/src/dom/gesture/gesture.ts index eef96f63..1edc7862 100644 --- a/packages/core/src/dom/gesture/gesture.ts +++ b/packages/core/src/dom/gesture/gesture.ts @@ -9,6 +9,7 @@ export interface GestureOptions { region?: GestureRegion | undefined; disabled?: boolean | undefined; action?: string | undefined; + value?: number | undefined; } export interface GestureBinding { @@ -19,6 +20,17 @@ export interface GestureBinding { region?: GestureRegion | undefined; disabled?: boolean | undefined; action?: string | undefined; + value?: number | undefined; +} + +export interface GestureActivateEvent { + type: GestureType; + source: 'gesture'; + action?: string | undefined; + value?: number | undefined; + region?: GestureRegion | undefined; + pointer?: GesturePointerType | undefined; + event: PointerEvent; } export interface GestureRecognizer { diff --git a/packages/core/src/dom/gesture/tests/coordinator.test.ts b/packages/core/src/dom/gesture/tests/coordinator.test.ts new file mode 100644 index 00000000..0c2c57aa --- /dev/null +++ b/packages/core/src/dom/gesture/tests/coordinator.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getGestureCoordinator } from '../coordinator'; +import { createDoubleTapGesture, createTapGesture } from '../create-tap-gesture'; + +function setup() { + const container = document.createElement('div'); + vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 300, + width: 300, + top: 0, + bottom: 200, + height: 200, + x: 0, + y: 0, + toJSON: () => {}, + }); + return container; +} + +describe('GestureCoordinator.subscribe', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('fires subscriber on tap', () => { + const container = setup(); + const subscriber = vi.fn(); + + getGestureCoordinator(container).subscribe(subscriber); + createTapGesture(container, vi.fn(), { action: 'togglePaused' }); + + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'mouse', clientX: 150 }); + + expect(subscriber).toHaveBeenCalledOnce(); + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'tap', action: 'togglePaused' })); + }); + + it('fires subscriber on doubletap', () => { + const container = setup(); + const subscriber = vi.fn(); + + getGestureCoordinator(container).subscribe(subscriber); + createDoubleTapGesture(container, vi.fn(), { action: 'seekStep', value: 10, region: 'right' }); + + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'mouse', clientX: 250 }); + + vi.advanceTimersByTime(100); + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'mouse', clientX: 250 }); + + expect(subscriber).toHaveBeenCalledOnce(); + expect(subscriber).toHaveBeenCalledWith( + expect.objectContaining({ type: 'doubletap', action: 'seekStep', value: 10, region: 'right' }) + ); + }); + + it('includes pointer type in subscriber event', () => { + const container = setup(); + const subscriber = vi.fn(); + + getGestureCoordinator(container).subscribe(subscriber); + createTapGesture(container, vi.fn(), { pointer: 'touch' }); + + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'touch', clientX: 150 }); + + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ pointer: 'touch' })); + }); + + it('returns unsubscribe function that stops callbacks', () => { + const container = setup(); + const subscriber = vi.fn(); + + const unsubscribe = getGestureCoordinator(container).subscribe(subscriber); + createTapGesture(container, vi.fn()); + + unsubscribe(); + + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'mouse', clientX: 150 }); + + expect(subscriber).not.toHaveBeenCalled(); + }); + + it('still invokes binding onActivate when a subscriber throws', () => { + const container = setup(); + const bindingActivate = vi.fn(); + + getGestureCoordinator(container).subscribe(() => { + throw new Error('subscriber boom'); + }); + createTapGesture(container, bindingActivate, { action: 'togglePaused' }); + + pointerDown(container); + vi.advanceTimersByTime(50); + pointerUp(container, { pointerType: 'mouse', clientX: 150 }); + + expect(bindingActivate).toHaveBeenCalledOnce(); + }); + + it('does not fire subscriber when gesture binding does not match', () => { + const container = setup(); + const subscriber = vi.fn(); + + getGestureCoordinator(container).subscribe(subscriber); + createTapGesture(container, vi.fn(), { pointer: 'touch' }); + + pointerDown(container); + vi.advanceTimersByTime(50); + // Fire with mouse, but binding is touch-only. + pointerUp(container, { pointerType: 'mouse', clientX: 150 }); + + expect(subscriber).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pointerDown(target: HTMLElement, init: { button?: number } = {}): void { + const event = new Event('pointerdown', { bubbles: true }); + Object.defineProperty(event, 'button', { value: init.button ?? 0 }); + target.dispatchEvent(event); +} + +function pointerUp(target: HTMLElement, init: { pointerType: string; clientX: number; button?: number }): void { + const event = new Event('pointerup', { bubbles: true }); + Object.defineProperty(event, 'pointerType', { value: init.pointerType }); + Object.defineProperty(event, 'clientX', { value: init.clientX }); + Object.defineProperty(event, 'button', { value: init.button ?? 0 }); + target.dispatchEvent(event); +} diff --git a/packages/core/src/dom/hotkey/actions.ts b/packages/core/src/dom/hotkey/actions.ts index bc43a2a8..2d5a3be7 100644 --- a/packages/core/src/dom/hotkey/actions.ts +++ b/packages/core/src/dom/hotkey/actions.ts @@ -1,11 +1,10 @@ import { isUndefined } from '@videojs/utils/predicate'; - import type { AnyPlayerStore } from '../media/types'; +import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions'; import { selectFullscreen, selectPiP, selectPlayback, - selectPlaybackRate, selectTextTrack, selectTime, selectVolume, @@ -63,37 +62,13 @@ const HOTKEY_ACTIONS: Record = { pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture(); }, - seekStep({ store, value }) { - if (isUndefined(value)) return; - const time = selectTime(store.state); - if (!time) return; - time.seek(time.currentTime + value); - }, + seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep, - volumeStep({ store, value }) { - if (isUndefined(value)) return; - const vol = selectVolume(store.state); - if (!vol) return; - vol.setVolume(vol.volume + value); - }, + volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep, - speedUp({ store }) { - const rate = selectPlaybackRate(store.state); - if (!rate) return; - const { playbackRates, playbackRate } = rate; - const idx = playbackRates.indexOf(playbackRate); - const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1; - rate.setPlaybackRate(playbackRates[next]!); - }, + speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp, - speedDown({ store }) { - const rate = selectPlaybackRate(store.state); - if (!rate) return; - const { playbackRates, playbackRate } = rate; - const idx = playbackRates.indexOf(playbackRate); - const next = idx <= 0 ? playbackRates.length - 1 : idx - 1; - rate.setPlaybackRate(playbackRates[next]!); - }, + speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown, seekToPercent({ store, value, key }) { const time = selectTime(store.state); diff --git a/packages/core/src/dom/hotkey/coordinator.ts b/packages/core/src/dom/hotkey/coordinator.ts index d3ca7988..aefd88d7 100644 --- a/packages/core/src/dom/hotkey/coordinator.ts +++ b/packages/core/src/dom/hotkey/coordinator.ts @@ -4,6 +4,13 @@ import { toAriaKeyShortcut } from './aria'; import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey'; import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey'; +export interface HotkeyActivateEvent { + source: 'hotkey'; + action?: string | undefined; + value?: number | undefined; + event: KeyboardEvent; +} + interface HotkeyBinding { parsed: ParsedHotkeyBinding[]; options: HotkeyOptions; @@ -19,12 +26,18 @@ export class HotkeyCoordinator { #docDisconnect: AbortController | null = null; /** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */ #ariaRegistry = new Map(); + #subscribers = new Set<(event: HotkeyActivateEvent) => void>(); #destroyed = false; constructor(target: HTMLElement) { this.#target = target; } + subscribe(callback: (event: HotkeyActivateEvent) => void): () => void { + this.#subscribers.add(callback); + return () => this.#subscribers.delete(callback); + } + add(options: HotkeyOptions): () => void { const parsed = parseHotkeyPattern(options.keys); const binding: HotkeyBinding = { parsed, options, id: this.#nextId++ }; @@ -141,6 +154,21 @@ export class HotkeyCoordinator { // Input safety: single-key shortcuts suppressed in editable fields. if (editable && p.modifiers.size === 0) continue; + if (this.#subscribers.size > 0) { + const activateEvent: HotkeyActivateEvent = { + source: 'hotkey', + action: options.action, + value: options.value, + event, + }; + for (const cb of this.#subscribers) { + try { + cb(activateEvent); + } catch (error) { + if (__DEV__) console.warn('[vjs-hotkey] subscribe callback threw:', error); + } + } + } event.preventDefault(); options.onActivate(event, p.originalKey); return; diff --git a/packages/core/src/dom/hotkey/hotkey.ts b/packages/core/src/dom/hotkey/hotkey.ts index d8e2a7d3..aaee7e90 100644 --- a/packages/core/src/dom/hotkey/hotkey.ts +++ b/packages/core/src/dom/hotkey/hotkey.ts @@ -20,8 +20,10 @@ export interface HotkeyOptions { /** Whether `event.repeat` should fire the callback. */ repeatable?: boolean | undefined; disabled?: boolean | undefined; - /** Action name for the ARIA registry. */ + /** Action name for the ARIA registry and subscriber events. */ action?: string | undefined; + /** Numeric magnitude passed to subscriber events (e.g. 10 for `seekStep`). */ + value?: number | undefined; } const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']); @@ -114,7 +116,8 @@ export function findHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator | return coordinators.get(target); } -function getCoordinator(target: HTMLElement): HotkeyCoordinator { +/** Look up or create the hotkey coordinator for a target element. */ +export function getHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator { let coordinator = coordinators.get(target); if (!coordinator) { coordinator = new HotkeyCoordinator(target); @@ -140,6 +143,6 @@ function getCoordinator(target: HTMLElement): HotkeyCoordinator { * @returns A cleanup function that removes the binding. */ export function createHotkey(target: HTMLElement, options: HotkeyOptions): () => void { - const coordinator = getCoordinator(target); + const coordinator = getHotkeyCoordinator(target); return coordinator.add(options); } diff --git a/packages/core/src/dom/hotkey/tests/coordinator.test.ts b/packages/core/src/dom/hotkey/tests/coordinator.test.ts index 25cfa2e2..328f7724 100644 --- a/packages/core/src/dom/hotkey/tests/coordinator.test.ts +++ b/packages/core/src/dom/hotkey/tests/coordinator.test.ts @@ -325,6 +325,35 @@ describe('HotkeyCoordinator', () => { }); }); + describe('subscribe', () => { + it('still invokes onActivate when a subscriber throws', () => { + const c = setup(); + const onActivate = vi.fn(); + c.subscribe(() => { + throw new Error('subscriber boom'); + }); + c.add({ keys: 'k', onActivate }); + + keydown(container, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + + it('runs subsequent subscribers after one throws', () => { + const c = setup(); + const second = vi.fn(); + c.subscribe(() => { + throw new Error('first'); + }); + c.subscribe(second); + c.add({ keys: 'k', onActivate: vi.fn() }); + + keydown(container, 'k'); + + expect(second).toHaveBeenCalledOnce(); + }); + }); + describe('ARIA registry', () => { it('returns undefined for unregistered action', () => { const c = setup(); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 828c6b15..c1f480b1 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -14,6 +14,7 @@ export * from './ui/alert-dialog'; export * from './ui/button'; export * from './ui/dismiss-layer'; export * from './ui/event'; +export * from './ui/input-action'; export * from './ui/popover/popover'; export * from './ui/popover/popover-positioning'; export * from './ui/slider'; diff --git a/packages/core/src/dom/media-actions.ts b/packages/core/src/dom/media-actions.ts new file mode 100644 index 00000000..6ebf0e5d --- /dev/null +++ b/packages/core/src/dom/media-actions.ts @@ -0,0 +1,47 @@ +import { isUndefined } from '@videojs/utils/predicate'; + +import type { AnyPlayerStore } from './media/types'; +import { selectPlaybackRate, selectTime, selectVolume } from './store/selectors'; + +export type MediaInputActionName = 'seekStep' | 'volumeStep' | 'speedUp' | 'speedDown'; + +export interface MediaInputActionContext { + store: AnyPlayerStore; + value?: number | undefined; +} + +export type MediaInputActionResolver = (context: MediaInputActionContext) => void; + +export const MEDIA_INPUT_ACTION_OVERRIDES: Record = { + seekStep({ store, value }) { + if (isUndefined(value)) return; + const time = selectTime(store.state); + if (!time) return; + time.seek(time.currentTime + value); + }, + + volumeStep({ store, value }) { + if (isUndefined(value)) return; + const vol = selectVolume(store.state); + if (!vol) return; + vol.setVolume(vol.volume + value); + }, + + speedUp({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1; + rate.setPlaybackRate(playbackRates[next]!); + }, + + speedDown({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx <= 0 ? playbackRates.length - 1 : idx - 1; + rate.setPlaybackRate(playbackRates[next]!); + }, +}; diff --git a/packages/core/src/dom/ui/input-action.ts b/packages/core/src/dom/ui/input-action.ts new file mode 100644 index 00000000..519ad4a9 --- /dev/null +++ b/packages/core/src/dom/ui/input-action.ts @@ -0,0 +1,72 @@ +import { IndicatorVisibilityCoordinator } from '../../core/ui/input-feedback/indicator-lifecycle'; +import type { InputActionEvent, MediaSnapshot } from '../../core/ui/input-feedback/status'; +import { getGestureCoordinator } from '../gesture/coordinator'; +import type { GestureActivateEvent } from '../gesture/gesture'; +import type { HotkeyActivateEvent } from '../hotkey/coordinator'; +import { getHotkeyCoordinator } from '../hotkey/hotkey'; +import { + selectFullscreen, + selectPiP, + selectPlayback, + selectTextTrack, + selectTime, + selectVolume, +} from '../store/selectors'; + +export type CoordinatorEvent = GestureActivateEvent | HotkeyActivateEvent; + +export interface MediaSnapshotStore { + readonly state: object; +} + +export function toInputActionEvent(event: CoordinatorEvent): InputActionEvent { + return { + action: event.action, + value: event.value, + source: event.source, + key: 'key' in event.event ? event.event.key : undefined, + }; +} + +export function getMediaSnapshot(store: MediaSnapshotStore | undefined): MediaSnapshot { + if (!store) return {}; + + const state = store.state; + const time = selectTime(state); + + return { + paused: selectPlayback(state)?.paused, + volume: selectVolume(state)?.volume, + muted: selectVolume(state)?.muted, + fullscreen: selectFullscreen(state)?.fullscreen, + subtitlesShowing: selectTextTrack(state)?.subtitlesShowing, + pip: selectPiP(state)?.pip, + currentTime: time?.currentTime, + duration: time?.duration, + }; +} + +export function subscribeToInputActions( + container: HTMLElement, + callback: (event: InputActionEvent) => void +): () => void { + const handleEvent = (event: CoordinatorEvent) => callback(toInputActionEvent(event)); + const gestureUnsubscribe = getGestureCoordinator(container).subscribe(handleEvent); + const hotkeyUnsubscribe = getHotkeyCoordinator(container).subscribe(handleEvent); + + return () => { + gestureUnsubscribe(); + hotkeyUnsubscribe(); + }; +} + +const indicatorVisibilityCoordinators = new WeakMap(); + +export function getIndicatorVisibilityCoordinator(container: HTMLElement): IndicatorVisibilityCoordinator { + let coordinator = indicatorVisibilityCoordinators.get(container); + if (!coordinator) { + coordinator = new IndicatorVisibilityCoordinator(); + indicatorVisibilityCoordinators.set(container, coordinator); + } + return coordinator; +} diff --git a/packages/core/src/dom/ui/tests/input-action.test.ts b/packages/core/src/dom/ui/tests/input-action.test.ts new file mode 100644 index 00000000..93fcad49 --- /dev/null +++ b/packages/core/src/dom/ui/tests/input-action.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + getIndicatorVisibilityCoordinator, + getMediaSnapshot, + type MediaSnapshotStore, + toInputActionEvent, +} from '../input-action'; + +function mockStore(state: Record): MediaSnapshotStore { + return { state }; +} + +describe('input-action', () => { + it('converts coordinator events to input action events', () => { + expect( + toInputActionEvent({ + source: 'hotkey', + action: 'togglePaused', + value: 1, + event: new KeyboardEvent('keydown', { key: 'k' }), + }) + ).toEqual({ + source: 'hotkey', + action: 'togglePaused', + value: 1, + key: 'k', + }); + }); + + it('derives media snapshots from player store selectors', () => { + expect( + getMediaSnapshot( + mockStore({ + chaptersCues: [], + paused: true, + volume: 0.5, + muted: false, + fullscreen: true, + subtitlesShowing: true, + pip: false, + currentTime: 30, + duration: 120, + }) + ) + ).toEqual({ + paused: true, + volume: 0.5, + muted: false, + fullscreen: true, + subtitlesShowing: true, + pip: false, + currentTime: 30, + duration: 120, + }); + }); + + it('shares a visibility coordinator per container', () => { + const container = document.createElement('div'); + const first = { close: vi.fn() }; + const second = { close: vi.fn() }; + + const coordinator = getIndicatorVisibilityCoordinator(container); + coordinator.register(first); + coordinator.register(second); + coordinator.show(second); + + expect(getIndicatorVisibilityCoordinator(container)).toBe(coordinator); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/html/src/define/audio/minimal-skin.css b/packages/html/src/define/audio/minimal-skin.css index c4239c54..21810537 100644 --- a/packages/html/src/define/audio/minimal-skin.css +++ b/packages/html/src/define/audio/minimal-skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/minimal/css/audio.css"; diff --git a/packages/html/src/define/audio/skin.css b/packages/html/src/define/audio/skin.css index 83ebd47b..e5bfa381 100644 --- a/packages/html/src/define/audio/skin.css +++ b/packages/html/src/define/audio/skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/default/css/audio.css"; diff --git a/packages/html/src/define/base.css b/packages/html/src/define/global.css similarity index 91% rename from packages/html/src/define/base.css rename to packages/html/src/define/global.css index 6c447691..c1602659 100644 --- a/packages/html/src/define/base.css +++ b/packages/html/src/define/global.css @@ -1,5 +1,5 @@ /* -------------------------------------------------------------------------- */ -/* Base */ +/* Global styles for the host document, outside of the Shadow DOM */ /* -------------------------------------------------------------------------- */ video-player, diff --git a/packages/html/src/define/live-audio/minimal-skin.css b/packages/html/src/define/live-audio/minimal-skin.css index c4239c54..21810537 100644 --- a/packages/html/src/define/live-audio/minimal-skin.css +++ b/packages/html/src/define/live-audio/minimal-skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/minimal/css/audio.css"; diff --git a/packages/html/src/define/live-audio/skin.css b/packages/html/src/define/live-audio/skin.css index 83ebd47b..e5bfa381 100644 --- a/packages/html/src/define/live-audio/skin.css +++ b/packages/html/src/define/live-audio/skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/default/css/audio.css"; diff --git a/packages/html/src/define/live-video/minimal-skin.css b/packages/html/src/define/live-video/minimal-skin.css index 28f9f8da..400e59a1 100644 --- a/packages/html/src/define/live-video/minimal-skin.css +++ b/packages/html/src/define/live-video/minimal-skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/minimal/css/video.css"; diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 96ffd98a..81149016 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -8,6 +8,7 @@ import { error, icon, iconState, + inputFeedback, overlay, popup, poster, @@ -104,6 +105,54 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + +
+ + + +
`; } diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 353f51d4..66717823 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -93,6 +93,54 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + +
+ + + +
`; } diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts index 479b01ec..d9c6fcb2 100644 --- a/packages/html/src/define/live-video/minimal-ui.ts +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -7,6 +7,8 @@ import { BufferingIndicatorElement } from '../../ui/buffering-indicator/bufferin import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; +import { GestureElement } from '../../ui/gesture/gesture-element'; +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; @@ -16,7 +18,14 @@ import { PosterElement } from '../../ui/poster/poster-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; import { safeDefine } from '../safe-define'; -import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { + defineControls, + defineErrorDialog, + defineInputIndicators, + defineTime, + defineTimeSlider, + defineVolumeSlider, +} from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { LiveVideoPlayerElement } from './player'; @@ -29,6 +38,7 @@ safeDefine(MediaContainerElement); // Compound groups. defineControls(); defineErrorDialog(); +defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); @@ -38,6 +48,8 @@ safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); +safeDefine(GestureElement); +safeDefine(HotkeyElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); diff --git a/packages/html/src/define/live-video/skin.css b/packages/html/src/define/live-video/skin.css index 15c9f8e0..368cb41f 100644 --- a/packages/html/src/define/live-video/skin.css +++ b/packages/html/src/define/live-video/skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/default/css/video.css"; diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index e7ee88a1..a34e43c9 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -8,6 +8,7 @@ import { error, icon, iconState, + inputFeedback, overlay, popup, poster, @@ -106,6 +107,53 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + +
+ + + +
`; } diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 5fbc2430..6495d7ba 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -110,6 +110,38 @@ function getTemplateHTML() { + + + +
+ + + +
`; } diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index b25d2f4d..a7218d46 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -17,7 +17,14 @@ import { PosterElement } from '../../ui/poster/poster-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; import { safeDefine } from '../safe-define'; -import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { + defineControls, + defineErrorDialog, + defineInputIndicators, + defineTime, + defineTimeSlider, + defineVolumeSlider, +} from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { LiveVideoPlayerElement } from './player'; @@ -30,6 +37,7 @@ safeDefine(MediaContainerElement); // Compound groups. defineControls(); defineErrorDialog(); +defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); diff --git a/packages/html/src/define/shared.css b/packages/html/src/define/shared.css index 76360bf4..50795d15 100644 --- a/packages/html/src/define/shared.css +++ b/packages/html/src/define/shared.css @@ -1,3 +1,7 @@ +/* -------------------------------------------------------------------------- */ +/* Shared styles for all HTML skins */ +/* -------------------------------------------------------------------------- */ + media-tooltip-group { display: contents; } diff --git a/packages/html/src/define/skin-element.ts b/packages/html/src/define/skin-element.ts index 13e17591..517f3125 100644 --- a/packages/html/src/define/skin-element.ts +++ b/packages/html/src/define/skin-element.ts @@ -6,7 +6,7 @@ import { renderTemplate, type ShadowStyle, } from '@videojs/utils/dom'; -import rootStyles from './base.css?inline'; +import globalStyles from './global.css?inline'; import sharedStyles from './shared.css?inline'; const STYLES_ID = '__media-styles'; @@ -25,7 +25,7 @@ export class SkinElement extends ReactiveElement { constructor() { super(); - ensureGlobalStyle(STYLES_ID, rootStyles); + ensureGlobalStyle(STYLES_ID, globalStyles); if (!this.shadowRoot) { const ctor = this.constructor as typeof SkinElement; diff --git a/packages/html/src/define/tailwind.css b/packages/html/src/define/tailwind.css new file mode 100644 index 00000000..8bb80f55 --- /dev/null +++ b/packages/html/src/define/tailwind.css @@ -0,0 +1 @@ +@import "@videojs/skins/shared/tailwind.css"; diff --git a/packages/html/src/define/ui/compounds.ts b/packages/html/src/define/ui/compounds.ts index 90953e7b..cdf3374a 100644 --- a/packages/html/src/define/ui/compounds.ts +++ b/packages/html/src/define/ui/compounds.ts @@ -4,6 +4,8 @@ import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-titl import { ControlsElement } from '../../ui/controls/controls-element'; import { ControlsGroupElement } from '../../ui/controls/controls-group-element'; import { ErrorDialogElement } from '../../ui/error-dialog/error-dialog-element'; +import { SeekIndicatorElement } from '../../ui/seek-indicator/seek-indicator-element'; +import { SeekIndicatorValueElement } from '../../ui/seek-indicator/seek-indicator-value-element'; import { SliderBufferElement } from '../../ui/slider/slider-buffer-element'; import { SliderElement } from '../../ui/slider/slider-element'; import { SliderFillElement } from '../../ui/slider/slider-fill-element'; @@ -12,10 +14,16 @@ import { SliderThumbElement } from '../../ui/slider/slider-thumb-element'; import { SliderThumbnailElement } from '../../ui/slider/slider-thumbnail-element'; import { SliderTrackElement } from '../../ui/slider/slider-track-element'; import { SliderValueElement } from '../../ui/slider/slider-value-element'; +import { StatusAnnouncerElement } from '../../ui/status-announcer/status-announcer-element'; +import { StatusIndicatorElement } from '../../ui/status-indicator/status-indicator-element'; +import { StatusIndicatorValueElement } from '../../ui/status-indicator/status-indicator-value-element'; import { TimeElement } from '../../ui/time/time-element'; import { TimeGroupElement } from '../../ui/time/time-group-element'; import { TimeSeparatorElement } from '../../ui/time/time-separator-element'; import { TimeSliderElement } from '../../ui/time-slider/time-slider-element'; +import { VolumeIndicatorElement } from '../../ui/volume-indicator/volume-indicator-element'; +import { VolumeIndicatorFillElement } from '../../ui/volume-indicator/volume-indicator-fill-element'; +import { VolumeIndicatorValueElement } from '../../ui/volume-indicator/volume-indicator-value-element'; import { VolumeSliderElement } from '../../ui/volume-slider/volume-slider-element'; import { safeDefine } from '../safe-define'; @@ -34,6 +42,17 @@ export function defineErrorDialog(): void { safeDefine(AlertDialogTitleElement); } +export function defineInputIndicators(): void { + safeDefine(StatusAnnouncerElement); + safeDefine(StatusIndicatorElement); + safeDefine(StatusIndicatorValueElement); + safeDefine(VolumeIndicatorElement); + safeDefine(VolumeIndicatorFillElement); + safeDefine(VolumeIndicatorValueElement); + safeDefine(SeekIndicatorElement); + safeDefine(SeekIndicatorValueElement); +} + /** Shared slider sub-elements used by all slider types. */ export function defineSliderParts(): void { safeDefine(SliderFillElement); diff --git a/packages/html/src/define/ui/seek-indicator-value.ts b/packages/html/src/define/ui/seek-indicator-value.ts new file mode 100644 index 00000000..4f119c65 --- /dev/null +++ b/packages/html/src/define/ui/seek-indicator-value.ts @@ -0,0 +1,10 @@ +import { SeekIndicatorValueElement } from '../../ui/seek-indicator/seek-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(SeekIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [SeekIndicatorValueElement.tagName]: SeekIndicatorValueElement; + } +} diff --git a/packages/html/src/define/ui/seek-indicator.ts b/packages/html/src/define/ui/seek-indicator.ts new file mode 100644 index 00000000..4d00b8ff --- /dev/null +++ b/packages/html/src/define/ui/seek-indicator.ts @@ -0,0 +1,13 @@ +import { SeekIndicatorElement } from '../../ui/seek-indicator/seek-indicator-element'; +import { SeekIndicatorValueElement } from '../../ui/seek-indicator/seek-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(SeekIndicatorElement); +safeDefine(SeekIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [SeekIndicatorElement.tagName]: SeekIndicatorElement; + [SeekIndicatorValueElement.tagName]: SeekIndicatorValueElement; + } +} diff --git a/packages/html/src/define/ui/status-announcer.ts b/packages/html/src/define/ui/status-announcer.ts new file mode 100644 index 00000000..48aa63be --- /dev/null +++ b/packages/html/src/define/ui/status-announcer.ts @@ -0,0 +1,10 @@ +import { StatusAnnouncerElement } from '../../ui/status-announcer/status-announcer-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(StatusAnnouncerElement); + +declare global { + interface HTMLElementTagNameMap { + [StatusAnnouncerElement.tagName]: StatusAnnouncerElement; + } +} diff --git a/packages/html/src/define/ui/status-indicator-value.ts b/packages/html/src/define/ui/status-indicator-value.ts new file mode 100644 index 00000000..fef9d9ca --- /dev/null +++ b/packages/html/src/define/ui/status-indicator-value.ts @@ -0,0 +1,10 @@ +import { StatusIndicatorValueElement } from '../../ui/status-indicator/status-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(StatusIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [StatusIndicatorValueElement.tagName]: StatusIndicatorValueElement; + } +} diff --git a/packages/html/src/define/ui/status-indicator.ts b/packages/html/src/define/ui/status-indicator.ts new file mode 100644 index 00000000..1dbd3fe8 --- /dev/null +++ b/packages/html/src/define/ui/status-indicator.ts @@ -0,0 +1,13 @@ +import { StatusIndicatorElement } from '../../ui/status-indicator/status-indicator-element'; +import { StatusIndicatorValueElement } from '../../ui/status-indicator/status-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(StatusIndicatorElement); +safeDefine(StatusIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [StatusIndicatorElement.tagName]: StatusIndicatorElement; + [StatusIndicatorValueElement.tagName]: StatusIndicatorValueElement; + } +} diff --git a/packages/html/src/define/ui/volume-indicator-fill.ts b/packages/html/src/define/ui/volume-indicator-fill.ts new file mode 100644 index 00000000..97a9a145 --- /dev/null +++ b/packages/html/src/define/ui/volume-indicator-fill.ts @@ -0,0 +1,10 @@ +import { VolumeIndicatorFillElement } from '../../ui/volume-indicator/volume-indicator-fill-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(VolumeIndicatorFillElement); + +declare global { + interface HTMLElementTagNameMap { + [VolumeIndicatorFillElement.tagName]: VolumeIndicatorFillElement; + } +} diff --git a/packages/html/src/define/ui/volume-indicator-value.ts b/packages/html/src/define/ui/volume-indicator-value.ts new file mode 100644 index 00000000..b1611a30 --- /dev/null +++ b/packages/html/src/define/ui/volume-indicator-value.ts @@ -0,0 +1,10 @@ +import { VolumeIndicatorValueElement } from '../../ui/volume-indicator/volume-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(VolumeIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [VolumeIndicatorValueElement.tagName]: VolumeIndicatorValueElement; + } +} diff --git a/packages/html/src/define/ui/volume-indicator.ts b/packages/html/src/define/ui/volume-indicator.ts new file mode 100644 index 00000000..b69ae5f8 --- /dev/null +++ b/packages/html/src/define/ui/volume-indicator.ts @@ -0,0 +1,16 @@ +import { VolumeIndicatorElement } from '../../ui/volume-indicator/volume-indicator-element'; +import { VolumeIndicatorFillElement } from '../../ui/volume-indicator/volume-indicator-fill-element'; +import { VolumeIndicatorValueElement } from '../../ui/volume-indicator/volume-indicator-value-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(VolumeIndicatorElement); +safeDefine(VolumeIndicatorFillElement); +safeDefine(VolumeIndicatorValueElement); + +declare global { + interface HTMLElementTagNameMap { + [VolumeIndicatorElement.tagName]: VolumeIndicatorElement; + [VolumeIndicatorFillElement.tagName]: VolumeIndicatorFillElement; + [VolumeIndicatorValueElement.tagName]: VolumeIndicatorValueElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.css b/packages/html/src/define/video/minimal-skin.css index 28f9f8da..400e59a1 100644 --- a/packages/html/src/define/video/minimal-skin.css +++ b/packages/html/src/define/video/minimal-skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/minimal/css/video.css"; diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 984e98f1..328182ff 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -10,6 +10,7 @@ import { iconContainer, iconFlipped, iconState, + inputFeedback, overlay, playbackRate, popup, @@ -152,6 +153,71 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
`; } diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index aed2ccba..3de02993 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -134,6 +134,68 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
`; } diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 306d4094..cdde2559 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -6,6 +6,8 @@ import { BufferingIndicatorElement } from '../../ui/buffering-indicator/bufferin import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; +import { GestureElement } from '../../ui/gesture/gesture-element'; +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; @@ -16,7 +18,14 @@ import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; import { safeDefine } from '../safe-define'; -import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { + defineControls, + defineErrorDialog, + defineInputIndicators, + defineTime, + defineTimeSlider, + defineVolumeSlider, +} from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { VideoPlayerElement } from './player'; @@ -29,6 +38,7 @@ safeDefine(MediaContainerElement); // Compound groups. defineControls(); defineErrorDialog(); +defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); @@ -38,6 +48,8 @@ safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); +safeDefine(GestureElement); +safeDefine(HotkeyElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/define/video/skin.css b/packages/html/src/define/video/skin.css index 15c9f8e0..368cb41f 100644 --- a/packages/html/src/define/video/skin.css +++ b/packages/html/src/define/video/skin.css @@ -1,3 +1,3 @@ -@import "../base.css"; +@import "../global.css"; @import "../shared.css"; @import "@videojs/skins/default/css/video.css"; diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index c397402f..0cb1484f 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -10,6 +10,7 @@ import { iconContainer, iconFlipped, iconState, + inputFeedback, overlay, playbackRate, popup, @@ -147,6 +148,70 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
`; } diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index f54979dd..bf16f4be 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -156,6 +156,42 @@ function getTemplateHTML() { + + + +
+ + + + +
`; } diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 3eb3afa3..ab3b0eb1 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -19,7 +19,14 @@ import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; import { safeDefine } from '../safe-define'; -import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { + defineControls, + defineErrorDialog, + defineInputIndicators, + defineTime, + defineTimeSlider, + defineVolumeSlider, +} from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { VideoPlayerElement } from './player'; @@ -32,6 +39,7 @@ safeDefine(MediaContainerElement); // Compound groups. defineControls(); defineErrorDialog(); +defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 261819b0..5017310a 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -53,6 +53,8 @@ export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-ra export { PopoverElement } from './ui/popover/popover-element'; export { PosterElement } from './ui/poster/poster-element'; export { SeekButtonElement } from './ui/seek-button/seek-button-element'; +export { SeekIndicatorElement } from './ui/seek-indicator/seek-indicator-element'; +export { SeekIndicatorValueElement } from './ui/seek-indicator/seek-indicator-value-element'; export { type SliderContextValue, sliderContext } from './ui/slider/context'; export { SliderBufferElement } from './ui/slider/slider-buffer-element'; export { SliderElement } from './ui/slider/slider-element'; @@ -63,6 +65,9 @@ export { SliderThumbElement } from './ui/slider/slider-thumb-element'; export { SliderThumbnailElement } from './ui/slider/slider-thumbnail-element'; export { SliderTrackElement } from './ui/slider/slider-track-element'; export { SliderValueElement } from './ui/slider/slider-value-element'; +export { StatusAnnouncerElement } from './ui/status-announcer/status-announcer-element'; +export { StatusIndicatorElement } from './ui/status-indicator/status-indicator-element'; +export { StatusIndicatorValueElement } from './ui/status-indicator/status-indicator-value-element'; export { ThumbnailElement } from './ui/thumbnail/thumbnail-element'; export { TimeElement } from './ui/time/time-element'; export { TimeGroupElement } from './ui/time/time-group-element'; @@ -71,4 +76,7 @@ export { TimeSliderElement } from './ui/time-slider/time-slider-element'; export { tooltipGroupContext } from './ui/tooltip/context'; export { TooltipElement } from './ui/tooltip/tooltip-element'; export { TooltipGroupElement } from './ui/tooltip/tooltip-group-element'; +export { VolumeIndicatorElement } from './ui/volume-indicator/volume-indicator-element'; +export { VolumeIndicatorFillElement } from './ui/volume-indicator/volume-indicator-fill-element'; +export { VolumeIndicatorValueElement } from './ui/volume-indicator/volume-indicator-value-element'; export { VolumeSliderElement } from './ui/volume-slider/volume-slider-element'; diff --git a/packages/html/src/ui/gesture/gesture-element.ts b/packages/html/src/ui/gesture/gesture-element.ts index 307a5971..36a7cf79 100644 --- a/packages/html/src/ui/gesture/gesture-element.ts +++ b/packages/html/src/ui/gesture/gesture-element.ts @@ -80,6 +80,7 @@ export class GestureElement extends MediaElement { region: this.region, disabled: this.disabled, action: this.action, + value: this.value, }; if (this.type === 'doubletap') { diff --git a/packages/html/src/ui/hotkey/hotkey-element.ts b/packages/html/src/ui/hotkey/hotkey-element.ts index ebf540df..faf203de 100644 --- a/packages/html/src/ui/hotkey/hotkey-element.ts +++ b/packages/html/src/ui/hotkey/hotkey-element.ts @@ -65,6 +65,7 @@ export class HotkeyElement extends MediaElement { this.#cleanup = createHotkey(container, { keys: this.keys, action, + value, target: this.target, disabled: this.disabled, repeatable: !isHotkeyToggleAction(action), diff --git a/packages/html/src/ui/input-indicators/input-indicator-element.ts b/packages/html/src/ui/input-indicators/input-indicator-element.ts new file mode 100644 index 00000000..b892abf5 --- /dev/null +++ b/packages/html/src/ui/input-indicators/input-indicator-element.ts @@ -0,0 +1,152 @@ +import { + getRenderedIndicatorState, + type IndicatorLifecycleState, + type IndicatorVisibilityHandle, + type InputActionEvent, + isIndicatorPresent, + type MediaSnapshot, +} from '@videojs/core'; +import { + getIndicatorVisibilityCoordinator, + getMediaSnapshot, + subscribeToInputActions, + type TransitionApi, +} from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; +import type { State as StoreState } from '@videojs/store'; + +import { containerContext, playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; +import type { LiveIndicator } from './live-indicator'; + +/** Shared imperative API for status / volume / seek indicator cores. */ +export interface InputIndicatorCoreApi { + readonly state: StoreState; + destroy(): void; + close(): void; + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean; +} + +export abstract class InputIndicatorElement extends MediaElement { + protected abstract get core(): InputIndicatorCoreApi; + protected abstract get transition(): TransitionApi; + protected abstract get liveIndicator(): LiveIndicator; + + protected abstract syncCoreProps(): void; + + protected readonly player = new PlayerController(this, playerContext); + protected readonly container = new ContextConsumer(this, { + context: containerContext, + callback: () => this.#reconnect(), + subscribe: true, + }); + + #disconnect: AbortController | null = null; + #inputActionUnsubscribe: (() => void) | null = null; + #visibilityUnsubscribe: (() => void) | null = null; + #visibilityHandle: IndicatorVisibilityHandle | null = null; + #lastGeneration = 0; + #snapshot: IndicatorState | null = null; + + #getVisibilityHandle(): IndicatorVisibilityHandle { + return (this.#visibilityHandle ??= { close: () => this.core.close() }); + } + + #payloadSnapshot(): IndicatorState { + return this.#snapshot ?? this.core.state.current; + } + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.#snapshot = this.core.state.current; + + this.#disconnect = new AbortController(); + this.core.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal }); + this.transition.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal }); + this.hidden = true; + this.#reconnect(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#inputActionUnsubscribe?.(); + this.#visibilityUnsubscribe?.(); + this.#inputActionUnsubscribe = null; + this.#visibilityUnsubscribe = null; + this.#disconnect?.abort(); + this.#disconnect = null; + } + + override destroyCallback(): void { + this.#inputActionUnsubscribe?.(); + this.#visibilityUnsubscribe?.(); + this.core.destroy(); + this.transition.destroy(); + this.liveIndicator.remove(); + super.destroyCallback(); + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.syncCoreProps(); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + this.#syncTransition(); + + const currentState = this.core.state.current; + const transitionState = this.transition.state.current; + const present = isIndicatorPresent(currentState, transitionState); + + if (!present) { + this.liveIndicator.remove(); + return; + } + + const state = getRenderedIndicatorState(currentState, this.#payloadSnapshot(), transitionState); + this.liveIndicator.render(state); + } + + #syncTransition(): void { + const currentState = this.core.state.current; + + if (currentState.open) { + this.#snapshot = currentState; + if (this.#lastGeneration !== currentState.generation) { + this.#lastGeneration = currentState.generation; + void this.transition.open(); + } + return; + } + + const { active, status } = this.transition.state.current; + if (active && status !== 'ending') { + void this.transition.close(this.liveIndicator.element); + } + } + + #reconnect(): void { + this.#inputActionUnsubscribe?.(); + this.#visibilityUnsubscribe?.(); + this.#inputActionUnsubscribe = null; + this.#visibilityUnsubscribe = null; + + const container = this.container.value?.container; + if (!container) return; + + const visibility = getIndicatorVisibilityCoordinator(container); + const visibilityHandle = this.#getVisibilityHandle(); + this.#visibilityUnsubscribe = visibility.register(visibilityHandle); + + this.#inputActionUnsubscribe = subscribeToInputActions(container, (event) => { + if (this.core.processEvent(event, getMediaSnapshot(this.player.value))) { + visibility.show(visibilityHandle); + } + }); + } +} diff --git a/packages/html/src/ui/input-indicators/live-indicator.ts b/packages/html/src/ui/input-indicators/live-indicator.ts new file mode 100644 index 00000000..5713fe81 --- /dev/null +++ b/packages/html/src/ui/input-indicators/live-indicator.ts @@ -0,0 +1,41 @@ +import type { StateAttrMap } from '@videojs/core'; +import { applyStateDataAttrs } from '@videojs/core/dom'; + +export interface LiveIndicatorOptions { + host: HTMLElement; + dataAttrs: StateAttrMap; + render: (element: HTMLElement, state: State) => void; +} + +export class LiveIndicator { + readonly #host: HTMLElement; + readonly #dataAttrs: StateAttrMap; + readonly #render: (element: HTMLElement, state: State) => void; + + constructor(options: LiveIndicatorOptions) { + this.#host = options.host; + this.#dataAttrs = options.dataAttrs; + this.#render = options.render; + } + + get element(): HTMLElement { + return this.#host; + } + + render(state: State): HTMLElement { + this.#host.hidden = false; + applyStateDataAttrs(this.#host, state, this.#dataAttrs); + this.#render(this.#host, state); + + return this.#host; + } + + remove(): void { + this.#host.hidden = true; + + for (const key in this.#dataAttrs) { + const name = this.#dataAttrs[key]; + if (name) this.#host.removeAttribute(name); + } + } +} diff --git a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts new file mode 100644 index 00000000..a523d868 --- /dev/null +++ b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts @@ -0,0 +1,101 @@ +import { + getVolumeIndicatorDisplayValue, + type VolumeIndicatorCore, + VolumeIndicatorCSSVars, + VolumeIndicatorDataAttrs, +} from '@videojs/core'; +import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SeekIndicatorElement } from '../../seek-indicator/seek-indicator-element'; +import { SeekIndicatorValueElement } from '../../seek-indicator/seek-indicator-value-element'; +import { StatusAnnouncerElement } from '../../status-announcer/status-announcer-element'; +import { StatusIndicatorElement } from '../../status-indicator/status-indicator-element'; +import { StatusIndicatorValueElement } from '../../status-indicator/status-indicator-value-element'; +import { VolumeIndicatorElement } from '../../volume-indicator/volume-indicator-element'; +import { VolumeIndicatorFillElement } from '../../volume-indicator/volume-indicator-fill-element'; +import { VolumeIndicatorValueElement } from '../../volume-indicator/volume-indicator-value-element'; +import { LiveIndicator } from '../live-indicator'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('input indicators', () => { + it('exposes standalone indicator tag names', () => { + expect(StatusIndicatorElement.tagName).toBe('media-status-indicator'); + expect(StatusIndicatorValueElement.tagName).toBe('media-status-indicator-value'); + expect(StatusAnnouncerElement.tagName).toBe('media-status-announcer'); + expect(VolumeIndicatorElement.tagName).toBe('media-volume-indicator'); + expect(VolumeIndicatorFillElement.tagName).toBe('media-volume-indicator-fill'); + expect(VolumeIndicatorValueElement.tagName).toBe('media-volume-indicator-value'); + expect(SeekIndicatorElement.tagName).toBe('media-seek-indicator'); + expect(SeekIndicatorValueElement.tagName).toBe('media-seek-indicator-value'); + }); + + it('uses authored HTML indicators as the mounted visual surface', () => { + const host = document.createElement('media-volume-indicator'); + host.hidden = true; + host.innerHTML = ` + + + + `; + document.body.append(host); + + const indicator = new LiveIndicator({ + host, + dataAttrs: VolumeIndicatorDataAttrs, + render: (element, state) => { + element + .querySelector('media-volume-indicator-fill') + ?.style.setProperty(VolumeIndicatorCSSVars.fill, state.fill ?? ''); + const value = element.querySelector('media-volume-indicator-value'); + if (value) value.textContent = getVolumeIndicatorDisplayValue(state); + }, + }); + + const liveElement = indicator.render({ + open: true, + generation: 1, + level: 'high', + value: '60%', + fill: '60%', + min: false, + max: false, + transitionStarting: true, + transitionEnding: false, + }); + + expect(liveElement).toBe(host); + expect(host.hidden).toBe(false); + expect(document.body.querySelectorAll('media-volume-indicator')).toHaveLength(1); + expect(liveElement.getAttribute('data-level')).toBe('high'); + expect(liveElement.querySelector('media-volume-indicator-value')?.textContent).toBe('60%'); + expect( + liveElement + .querySelector('media-volume-indicator-fill') + ?.style.getPropertyValue(VolumeIndicatorCSSVars.fill) + ).toBe('60%'); + + indicator.remove(); + expect(host.hidden).toBe(true); + expect(document.body.querySelectorAll('media-volume-indicator')).toHaveLength(1); + expect(host.hasAttribute('data-open')).toBe(false); + expect(host.hasAttribute('data-level')).toBe(false); + }); + + it('shares a visibility coordinator per container', () => { + const container = document.createElement('div'); + const first = { close: vi.fn() }; + const second = { close: vi.fn() }; + + const coordinator = getIndicatorVisibilityCoordinator(container); + coordinator.register(first); + coordinator.register(second); + coordinator.show(second); + + expect(getIndicatorVisibilityCoordinator(container)).toBe(coordinator); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/html/src/ui/seek-indicator/seek-indicator-element.ts b/packages/html/src/ui/seek-indicator/seek-indicator-element.ts new file mode 100644 index 00000000..610d4263 --- /dev/null +++ b/packages/html/src/ui/seek-indicator/seek-indicator-element.ts @@ -0,0 +1,47 @@ +import { getSeekIndicatorDisplayValue, SeekIndicatorCore, SeekIndicatorDataAttrs } from '@videojs/core'; +import { createTransition } from '@videojs/core/dom'; +import type { PropertyDeclarationMap } from '@videojs/element'; + +import { InputIndicatorElement } from '../input-indicators/input-indicator-element'; +import { LiveIndicator } from '../input-indicators/live-indicator'; + +export class SeekIndicatorElement extends InputIndicatorElement { + static readonly tagName = 'media-seek-indicator'; + + static override properties = { + closeDelay: { type: Number, attribute: 'close-delay' }, + } satisfies PropertyDeclarationMap<'closeDelay'>; + + closeDelay: number | undefined; + + readonly #core = new SeekIndicatorCore(); + readonly #transition = createTransition(); + readonly #liveIndicator = new LiveIndicator({ + host: this, + dataAttrs: SeekIndicatorDataAttrs, + render: renderSeekIndicator, + }); + + protected get core() { + return this.#core; + } + + protected get transition() { + return this.#transition; + } + + protected get liveIndicator() { + return this.#liveIndicator; + } + + protected override syncCoreProps(): void { + this.#core.setProps({ closeDelay: this.closeDelay }); + } +} + +function renderSeekIndicator(element: HTMLElement, state: SeekIndicatorCore.State): void { + const value = element.querySelector('media-seek-indicator-value'); + if (!value) return; + + value.textContent = getSeekIndicatorDisplayValue(state); +} diff --git a/packages/html/src/ui/seek-indicator/seek-indicator-value-element.ts b/packages/html/src/ui/seek-indicator/seek-indicator-value-element.ts new file mode 100644 index 00000000..0821e6b5 --- /dev/null +++ b/packages/html/src/ui/seek-indicator/seek-indicator-value-element.ts @@ -0,0 +1,5 @@ +import { MediaElement } from '../media-element'; + +export class SeekIndicatorValueElement extends MediaElement { + static readonly tagName = 'media-seek-indicator-value'; +} diff --git a/packages/html/src/ui/status-announcer/status-announcer-element.ts b/packages/html/src/ui/status-announcer/status-announcer-element.ts new file mode 100644 index 00000000..e409204c --- /dev/null +++ b/packages/html/src/ui/status-announcer/status-announcer-element.ts @@ -0,0 +1,82 @@ +import { StatusAnnouncerCore } from '@videojs/core'; +import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { containerContext, playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +export class StatusAnnouncerElement extends MediaElement { + static readonly tagName = 'media-status-announcer'; + + static override properties = { + closeDelay: { type: Number, attribute: 'close-delay' }, + } satisfies PropertyDeclarationMap<'closeDelay'>; + + closeDelay: number | undefined; + + readonly #core = new StatusAnnouncerCore(); + readonly #player = new PlayerController(this, playerContext); + readonly #container = new ContextConsumer(this, { + context: containerContext, + callback: () => this.#reconnect(), + subscribe: true, + }); + + #disconnect: AbortController | null = null; + #inputActionUnsubscribe: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.setAttribute('role', 'status'); + + this.#disconnect = new AbortController(); + this.#core.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal }); + this.#reconnect(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#inputActionUnsubscribe?.(); + this.#inputActionUnsubscribe = null; + this.#disconnect?.abort(); + this.#disconnect = null; + } + + override destroyCallback(): void { + this.#inputActionUnsubscribe?.(); + this.#core.destroy(); + super.destroyCallback(); + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.#core.setProps({ closeDelay: this.closeDelay }); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const label = this.#core.state.current.label; + if (label) { + this.setAttribute('aria-label', label); + } else { + this.removeAttribute('aria-label'); + } + } + + #reconnect(): void { + this.#inputActionUnsubscribe?.(); + this.#inputActionUnsubscribe = null; + + const container = this.#container.value?.container; + if (!container) return; + + this.#inputActionUnsubscribe = subscribeToInputActions(container, (event) => { + this.#core.processEvent(event, getMediaSnapshot(this.#player.value)); + }); + } +} diff --git a/packages/html/src/ui/status-indicator/status-indicator-element.ts b/packages/html/src/ui/status-indicator/status-indicator-element.ts new file mode 100644 index 00000000..668bb129 --- /dev/null +++ b/packages/html/src/ui/status-indicator/status-indicator-element.ts @@ -0,0 +1,58 @@ +import { + getStatusIndicatorDisplayValue, + type InputAction, + StatusIndicatorCore, + StatusIndicatorDataAttrs, +} from '@videojs/core'; +import { createTransition } from '@videojs/core/dom'; +import type { PropertyDeclarationMap } from '@videojs/element'; + +import { InputIndicatorElement } from '../input-indicators/input-indicator-element'; +import { LiveIndicator } from '../input-indicators/live-indicator'; + +export class StatusIndicatorElement extends InputIndicatorElement { + static readonly tagName = 'media-status-indicator'; + + static override properties = { + actions: { type: String }, + closeDelay: { type: Number, attribute: 'close-delay' }, + } satisfies PropertyDeclarationMap<'actions' | 'closeDelay'>; + + actions: string | undefined; + closeDelay: number | undefined; + + readonly #core = new StatusIndicatorCore(); + readonly #transition = createTransition(); + readonly #liveIndicator = new LiveIndicator({ + host: this, + dataAttrs: StatusIndicatorDataAttrs, + render: renderStatusIndicator, + }); + + protected get core() { + return this.#core; + } + + protected get transition() { + return this.#transition; + } + + protected get liveIndicator() { + return this.#liveIndicator; + } + + protected override syncCoreProps(): void { + this.#core.setProps({ actions: parseActions(this.actions), closeDelay: this.closeDelay }); + } +} + +function parseActions(actions: string | undefined): readonly InputAction[] | undefined { + return actions?.split(/[\s,]+/).filter(Boolean) as readonly InputAction[] | undefined; +} + +function renderStatusIndicator(element: HTMLElement, state: StatusIndicatorCore.State): void { + const value = element.querySelector('media-status-indicator-value'); + if (!value) return; + + value.textContent = getStatusIndicatorDisplayValue(state); +} diff --git a/packages/html/src/ui/status-indicator/status-indicator-value-element.ts b/packages/html/src/ui/status-indicator/status-indicator-value-element.ts new file mode 100644 index 00000000..7db65ee4 --- /dev/null +++ b/packages/html/src/ui/status-indicator/status-indicator-value-element.ts @@ -0,0 +1,5 @@ +import { MediaElement } from '../media-element'; + +export class StatusIndicatorValueElement extends MediaElement { + static readonly tagName = 'media-status-indicator-value'; +} diff --git a/packages/html/src/ui/volume-indicator/volume-indicator-element.ts b/packages/html/src/ui/volume-indicator/volume-indicator-element.ts new file mode 100644 index 00000000..f3497889 --- /dev/null +++ b/packages/html/src/ui/volume-indicator/volume-indicator-element.ts @@ -0,0 +1,60 @@ +import { + getVolumeIndicatorDisplayValue, + VolumeIndicatorCore, + VolumeIndicatorCSSVars, + VolumeIndicatorDataAttrs, +} from '@videojs/core'; +import { createTransition } from '@videojs/core/dom'; +import type { PropertyDeclarationMap } from '@videojs/element'; + +import { InputIndicatorElement } from '../input-indicators/input-indicator-element'; +import { LiveIndicator } from '../input-indicators/live-indicator'; + +export class VolumeIndicatorElement extends InputIndicatorElement { + static readonly tagName = 'media-volume-indicator'; + + static override properties = { + closeDelay: { type: Number, attribute: 'close-delay' }, + } satisfies PropertyDeclarationMap<'closeDelay'>; + + closeDelay: number | undefined; + + readonly #core = new VolumeIndicatorCore(); + readonly #transition = createTransition(); + readonly #liveIndicator = new LiveIndicator({ + host: this, + dataAttrs: VolumeIndicatorDataAttrs, + render: renderVolumeIndicator, + }); + + protected get core() { + return this.#core; + } + + protected get transition() { + return this.#transition; + } + + protected get liveIndicator() { + return this.#liveIndicator; + } + + protected override syncCoreProps(): void { + this.#core.setProps({ closeDelay: this.closeDelay }); + } +} + +function renderVolumeIndicator(element: HTMLElement, state: VolumeIndicatorCore.State): void { + const fill = element.querySelector('media-volume-indicator-fill'); + const value = element.querySelector('media-volume-indicator-value'); + + if (state.fill) { + fill?.style.setProperty(VolumeIndicatorCSSVars.fill, state.fill); + } else { + fill?.style.removeProperty(VolumeIndicatorCSSVars.fill); + } + + if (value) { + value.textContent = getVolumeIndicatorDisplayValue(state); + } +} diff --git a/packages/html/src/ui/volume-indicator/volume-indicator-fill-element.ts b/packages/html/src/ui/volume-indicator/volume-indicator-fill-element.ts new file mode 100644 index 00000000..412057f7 --- /dev/null +++ b/packages/html/src/ui/volume-indicator/volume-indicator-fill-element.ts @@ -0,0 +1,5 @@ +import { MediaElement } from '../media-element'; + +export class VolumeIndicatorFillElement extends MediaElement { + static readonly tagName = 'media-volume-indicator-fill'; +} diff --git a/packages/html/src/ui/volume-indicator/volume-indicator-value-element.ts b/packages/html/src/ui/volume-indicator/volume-indicator-value-element.ts new file mode 100644 index 00000000..0dd8aa9d --- /dev/null +++ b/packages/html/src/ui/volume-indicator/volume-indicator-value-element.ts @@ -0,0 +1,5 @@ +import { MediaElement } from '../media-element'; + +export class VolumeIndicatorValueElement extends MediaElement { + static readonly tagName = 'media-volume-indicator-value'; +} diff --git a/packages/icons/package.json b/packages/icons/package.json index fa41f598..b006f18a 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -54,7 +54,8 @@ "build": "node --import tsx scripts/build.ts", "build:watch": "pnpm run build -- --watch", "dev": "pnpm run build:watch", - "clean": "rimraf dist" + "clean": "rimraf dist", + "format": "node --import tsx scripts/format.ts" }, "dependencies": { "svgo": "^4.0.1" @@ -62,7 +63,6 @@ "devDependencies": { "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", - "@svgr/plugin-svgo": "^8.1.0", "@types/react": "^19.2.14", "@videojs/utils": "workspace:*", "react": "^19.2.4", diff --git a/packages/icons/scripts/build.ts b/packages/icons/scripts/build.ts index e6d2a84b..73cf197d 100644 --- a/packages/icons/scripts/build.ts +++ b/packages/icons/scripts/build.ts @@ -1,48 +1,39 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; const isWatch = process.argv.includes('--watch'); import { transform } from '@svgr/core'; import { camelCase, pascalCase } from '@videojs/utils/string'; import { transform as esbuildTransform } from 'esbuild'; -import { type Config, optimize } from 'svgo'; +import { optimize } from 'svgo'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); -const ASSETS_DIR = join(ROOT, 'src/assets'); -const DIST_DIR = join(ROOT, 'dist'); +import { + ASSETS_DIR, + createSvgoConfig, + DIST_DIR, + getIconSets, + getSvgFiles, + PRESET_DEFAULT_OVERRIDES, + REMOVE_ATTRS_PLUGIN, + replaceColors, +} from './shared.js'; const FRAMEWORKS = ['react', 'html'] as const; -const SVGO_CONFIG: Config = { - multipass: true, - plugins: [ - { - name: 'preset-default', - params: { - overrides: { - convertColors: { - currentColor: /^black$/, - }, - }, - }, +const SVGO_CONFIG = createSvgoConfig([ + { + name: 'preset-default', + params: { overrides: PRESET_DEFAULT_OVERRIDES }, + }, + REMOVE_ATTRS_PLUGIN, + { + name: 'addAttributesToSVGElement', + params: { + attributes: [{ 'aria-hidden': 'true' }], }, - { - name: 'removeAttrs', - params: { - attrs: ['^clip-rule$', '^fill-rule$'], - }, - }, - { - name: 'addAttributesToSVGElement', - params: { - attributes: [{ 'aria-hidden': 'true' }], - }, - }, - ], -}; + }, +]); function ensureDir(path: string): void { if (!existsSync(path)) mkdirSync(path, { recursive: true }); @@ -52,36 +43,21 @@ function cleanDist(): void { if (existsSync(DIST_DIR)) rmSync(DIST_DIR, { recursive: true, force: true }); } -function getIconSets(): string[] { - if (!existsSync(ASSETS_DIR)) { - console.error(`Assets directory not found: ${ASSETS_DIR}`); - process.exit(1); - } - return readdirSync(ASSETS_DIR).filter((item) => !item.startsWith('.') && item !== 'index'); -} - -function getSvgFiles(setName: string): string[] { - return readdirSync(join(ASSETS_DIR, setName)).filter((f) => f.endsWith('.svg')); -} - function optimizeSvg(svgContent: string): string { - const optimized = optimize(svgContent, SVGO_CONFIG).data; - return optimized - .replaceAll('fill="black"', 'fill="currentColor"') - .replaceAll('stroke="black"', 'stroke="currentColor"'); + return replaceColors(optimize(svgContent, SVGO_CONFIG).data); } async function buildReactComponent(svgContent: string, componentName: string): Promise<{ js: string; tsx: string }> { + const optimized = optimizeSvg(svgContent); + const transformOpts: Parameters[1] = { - plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + plugins: ['@svgr/plugin-jsx'], jsxRuntime: 'automatic', - svgoConfig: SVGO_CONFIG, }; - const tsxCode = await transform(svgContent, { ...transformOpts, typescript: true }, { componentName }); - const jsxCode = await transform(svgContent, transformOpts, { componentName }); + const tsxCode = await transform(optimized, { ...transformOpts, typescript: true }, { componentName }); + const jsxCode = await transform(optimized, transformOpts, { componentName }); - // SVGR outputs JSX syntax which is invalid in .js files — compile to JS const { code } = await esbuildTransform(jsxCode, { loader: 'jsx', jsx: 'automatic' }); return { js: code, tsx: tsxCode }; diff --git a/packages/icons/scripts/format.ts b/packages/icons/scripts/format.ts new file mode 100644 index 00000000..5cf579d1 --- /dev/null +++ b/packages/icons/scripts/format.ts @@ -0,0 +1,130 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { CustomPlugin, XastElement } from 'svgo'; +import { optimize } from 'svgo'; + +import { + ASSETS_DIR, + createSvgoConfig, + getIconSets, + getSvgFiles, + PRESET_DEFAULT_OVERRIDES, + REMOVE_ATTRS_PLUGIN, + replaceColors, +} from './shared.js'; + +const SHAPES = new Set(['circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect']); + +function allShapesUseCurrentColor(node: XastElement, inheritedFill: string): boolean { + for (const child of node.children) { + if (child.type !== 'element') continue; + + const effectiveFill = child.attributes.fill ?? inheritedFill; + + if (SHAPES.has(child.name)) { + if (effectiveFill !== 'currentColor') return false; + } else if (!allShapesUseCurrentColor(child, effectiveFill)) { + return false; + } + } + return true; +} + +function hasShapeDescendant(node: XastElement): boolean { + for (const child of node.children) { + if (child.type !== 'element') continue; + if (SHAPES.has(child.name) || hasShapeDescendant(child)) return true; + } + return false; +} + +function removeFillCurrentColor(node: XastElement): void { + if (node.attributes.fill === 'currentColor') { + delete node.attributes.fill; + } + for (const child of node.children) { + if (child.type === 'element') removeFillCurrentColor(child); + } +} + +/** + * When the root `` has `fill="none"` but every shape descendant uses + * `fill="currentColor"` (directly or inherited from a ``), hoist + * `fill="currentColor"` to the root and strip it from descendants. + * + * With `multipass: true`, SVGO's `collapseGroups` will then clean up any + * `` elements left with no attributes on the next pass. + */ +const hoistCurrentColorFill: CustomPlugin = { + name: 'hoistCurrentColorFill', + fn: () => ({ + element: { + exit(node) { + if (node.name !== 'svg') return; + if (node.attributes.fill !== 'none') return; + if (!hasShapeDescendant(node)) return; + if (!allShapesUseCurrentColor(node, 'none')) return; + + node.attributes.fill = 'currentColor'; + + for (const child of node.children) { + if (child.type === 'element') removeFillCurrentColor(child); + } + }, + }, + }), +}; + +const SVGO_CONFIG = createSvgoConfig( + [ + { + name: 'preset-default', + params: { + overrides: { + ...PRESET_DEFAULT_OVERRIDES, + convertShapeToPath: false, + }, + }, + }, + REMOVE_ATTRS_PLUGIN, + hoistCurrentColorFill, + ], + { + js2svg: { + indent: 2, + pretty: true, + }, + } +); + +function formatFile(filePath: string): boolean { + const input = readFileSync(filePath, 'utf8'); + const formatted = replaceColors(optimize(input, SVGO_CONFIG).data); + + if (formatted !== input) { + writeFileSync(filePath, formatted); + return true; + } + + return false; +} + +function getAllSvgFiles(): string[] { + return getIconSets().flatMap((set) => getSvgFiles(set).map((file) => join(ASSETS_DIR, set, file))); +} + +const files = process.argv.length > 2 ? process.argv.slice(2) : getAllSvgFiles(); + +let changed = 0; + +for (const file of files) { + if (formatFile(file)) { + console.log(` formatted: ${file}`); + changed++; + } +} + +if (changed > 0) { + console.log(`\nFormatted ${changed} of ${files.length} SVG files.`); +} diff --git a/packages/icons/scripts/shared.ts b/packages/icons/scripts/shared.ts new file mode 100644 index 00000000..55e342d6 --- /dev/null +++ b/packages/icons/scripts/shared.ts @@ -0,0 +1,44 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { Config, PluginConfig } from 'svgo'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const ROOT = join(__dirname, '..'); +export const ASSETS_DIR = join(ROOT, 'src/assets'); +export const DIST_DIR = join(ROOT, 'dist'); + +export const PRESET_DEFAULT_OVERRIDES = { + convertColors: { + currentColor: /^black$/, + }, +} as const; + +export const REMOVE_ATTRS_PLUGIN: PluginConfig = { + name: 'removeAttrs', + params: { + attrs: ['^clip-rule$', '^fill-rule$'], + }, +}; + +export function createSvgoConfig(plugins: PluginConfig[], options?: Omit): Config { + return { multipass: true, ...options, plugins }; +} + +export function replaceColors(svg: string): string { + return svg.replaceAll('fill="black"', 'fill="currentColor"').replaceAll('stroke="black"', 'stroke="currentColor"'); +} + +export function getIconSets(): string[] { + if (!existsSync(ASSETS_DIR)) { + console.error(`Assets directory not found: ${ASSETS_DIR}`); + process.exit(1); + } + return readdirSync(ASSETS_DIR).filter((item) => !item.startsWith('.') && item !== 'index'); +} + +export function getSvgFiles(setName: string): string[] { + return readdirSync(join(ASSETS_DIR, setName)).filter((f) => f.endsWith('.svg')); +} diff --git a/packages/icons/src/assets/default/captions-off.svg b/packages/icons/src/assets/default/captions-off.svg index a64feb4d..1aa343cb 100644 --- a/packages/icons/src/assets/default/captions-off.svg +++ b/packages/icons/src/assets/default/captions-off.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + diff --git a/packages/icons/src/assets/default/captions-on.svg b/packages/icons/src/assets/default/captions-on.svg index 8309d7bd..71426167 100644 --- a/packages/icons/src/assets/default/captions-on.svg +++ b/packages/icons/src/assets/default/captions-on.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/chevron.svg b/packages/icons/src/assets/default/chevron.svg new file mode 100644 index 00000000..fccd81e3 --- /dev/null +++ b/packages/icons/src/assets/default/chevron.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/icons/src/assets/default/fullscreen-enter.svg b/packages/icons/src/assets/default/fullscreen-enter.svg index 5abf4719..51e86f20 100644 --- a/packages/icons/src/assets/default/fullscreen-enter.svg +++ b/packages/icons/src/assets/default/fullscreen-enter.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/packages/icons/src/assets/default/fullscreen-exit.svg b/packages/icons/src/assets/default/fullscreen-exit.svg index 66d75161..9b44e9c6 100644 --- a/packages/icons/src/assets/default/fullscreen-exit.svg +++ b/packages/icons/src/assets/default/fullscreen-exit.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/packages/icons/src/assets/default/pause.svg b/packages/icons/src/assets/default/pause.svg index 9c9a987e..fd23fb8a 100644 --- a/packages/icons/src/assets/default/pause.svg +++ b/packages/icons/src/assets/default/pause.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/pip-enter.svg b/packages/icons/src/assets/default/pip-enter.svg index 87076850..bf486975 100644 --- a/packages/icons/src/assets/default/pip-enter.svg +++ b/packages/icons/src/assets/default/pip-enter.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/packages/icons/src/assets/default/pip-exit.svg b/packages/icons/src/assets/default/pip-exit.svg index 59433e44..d744dde3 100644 --- a/packages/icons/src/assets/default/pip-exit.svg +++ b/packages/icons/src/assets/default/pip-exit.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/packages/icons/src/assets/default/play.svg b/packages/icons/src/assets/default/play.svg index 61cc47ac..fb3f7fb9 100644 --- a/packages/icons/src/assets/default/play.svg +++ b/packages/icons/src/assets/default/play.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/restart.svg b/packages/icons/src/assets/default/restart.svg index f33253f8..c4754be3 100644 --- a/packages/icons/src/assets/default/restart.svg +++ b/packages/icons/src/assets/default/restart.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/seek.svg b/packages/icons/src/assets/default/seek.svg index a785e41b..252e5f17 100644 --- a/packages/icons/src/assets/default/seek.svg +++ b/packages/icons/src/assets/default/seek.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/volume-high.svg b/packages/icons/src/assets/default/volume-high.svg index 74c2377e..2d2f8a0d 100644 --- a/packages/icons/src/assets/default/volume-high.svg +++ b/packages/icons/src/assets/default/volume-high.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/volume-low.svg b/packages/icons/src/assets/default/volume-low.svg index a004a971..f55bc37b 100644 --- a/packages/icons/src/assets/default/volume-low.svg +++ b/packages/icons/src/assets/default/volume-low.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/volume-off.svg b/packages/icons/src/assets/default/volume-off.svg index 0c59006c..a9d085bf 100644 --- a/packages/icons/src/assets/default/volume-off.svg +++ b/packages/icons/src/assets/default/volume-off.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/captions-off.svg b/packages/icons/src/assets/minimal/captions-off.svg index f0a6fdec..e4b6df99 100644 --- a/packages/icons/src/assets/minimal/captions-off.svg +++ b/packages/icons/src/assets/minimal/captions-off.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + diff --git a/packages/icons/src/assets/minimal/captions-on.svg b/packages/icons/src/assets/minimal/captions-on.svg index 3aded322..eb886907 100644 --- a/packages/icons/src/assets/minimal/captions-on.svg +++ b/packages/icons/src/assets/minimal/captions-on.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/chevron.svg b/packages/icons/src/assets/minimal/chevron.svg new file mode 100644 index 00000000..2d688bf4 --- /dev/null +++ b/packages/icons/src/assets/minimal/chevron.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/icons/src/assets/minimal/fullscreen-enter.svg b/packages/icons/src/assets/minimal/fullscreen-enter.svg index 873a94ef..e2a028b4 100644 --- a/packages/icons/src/assets/minimal/fullscreen-enter.svg +++ b/packages/icons/src/assets/minimal/fullscreen-enter.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/packages/icons/src/assets/minimal/fullscreen-exit.svg b/packages/icons/src/assets/minimal/fullscreen-exit.svg index 8ffadf0f..39c65073 100644 --- a/packages/icons/src/assets/minimal/fullscreen-exit.svg +++ b/packages/icons/src/assets/minimal/fullscreen-exit.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/packages/icons/src/assets/minimal/pause.svg b/packages/icons/src/assets/minimal/pause.svg index a5a1e0db..951878aa 100644 --- a/packages/icons/src/assets/minimal/pause.svg +++ b/packages/icons/src/assets/minimal/pause.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/minimal/pip-enter.svg b/packages/icons/src/assets/minimal/pip-enter.svg index 7c26a0a1..c452b743 100644 --- a/packages/icons/src/assets/minimal/pip-enter.svg +++ b/packages/icons/src/assets/minimal/pip-enter.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/icons/src/assets/minimal/pip-exit.svg b/packages/icons/src/assets/minimal/pip-exit.svg index ac3e706f..cc59d77b 100644 --- a/packages/icons/src/assets/minimal/pip-exit.svg +++ b/packages/icons/src/assets/minimal/pip-exit.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/icons/src/assets/minimal/play.svg b/packages/icons/src/assets/minimal/play.svg index 68a80573..c9902500 100644 --- a/packages/icons/src/assets/minimal/play.svg +++ b/packages/icons/src/assets/minimal/play.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/restart.svg b/packages/icons/src/assets/minimal/restart.svg index 6a058b5c..e04f0502 100644 --- a/packages/icons/src/assets/minimal/restart.svg +++ b/packages/icons/src/assets/minimal/restart.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/minimal/seek.svg b/packages/icons/src/assets/minimal/seek.svg index 4784dde0..9087f674 100644 --- a/packages/icons/src/assets/minimal/seek.svg +++ b/packages/icons/src/assets/minimal/seek.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/volume-high.svg b/packages/icons/src/assets/minimal/volume-high.svg index 74c2377e..2d2f8a0d 100644 --- a/packages/icons/src/assets/minimal/volume-high.svg +++ b/packages/icons/src/assets/minimal/volume-high.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/minimal/volume-low.svg b/packages/icons/src/assets/minimal/volume-low.svg index a004a971..f55bc37b 100644 --- a/packages/icons/src/assets/minimal/volume-low.svg +++ b/packages/icons/src/assets/minimal/volume-low.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/volume-off.svg b/packages/icons/src/assets/minimal/volume-off.svg index 0c59006c..a9d085bf 100644 --- a/packages/icons/src/assets/minimal/volume-off.svg +++ b/packages/icons/src/assets/minimal/volume-off.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/react/package.json b/packages/react/package.json index 01c5add4..6c6b873b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -22,6 +22,7 @@ "development": "./dist/dev/index.js", "default": "./dist/default/index.js" }, + "./*.css": "./dist/default/*.css", "./icons": { "types": "./dist/dev/icons/index.d.ts", "development": "./dist/dev/icons/index.js", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a4f61425..1e6281e6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,13 +1,12 @@ 'use client'; +export type { IndicatorStatus, InputAction, InputIndicatorLabels } from '@videojs/core'; // Core export * from '@videojs/core/dom'; - // Store export type { Comparator, Selector } from '@videojs/store'; export { createSelector, shallowEqual } from '@videojs/store'; export { useSelector, useStore } from '@videojs/store/react'; - // Media primitives export { Container, @@ -28,7 +27,6 @@ export { createPlayer, type ProviderProps, } from './player/create-player'; - // UI export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog'; export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator'; @@ -55,6 +53,9 @@ export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback- export { Popover, type PopoverContextValue, usePopoverContext } from './ui/popover'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; +export { SeekIndicator } from './ui/seek-indicator'; +export type { SeekIndicatorRootProps } from './ui/seek-indicator/seek-indicator-root'; +export type { SeekIndicatorValueProps } from './ui/seek-indicator/seek-indicator-value'; export { Slider } from './ui/slider'; export type { SliderBufferProps } from './ui/slider/slider-buffer'; export type { SliderFillProps } from './ui/slider/slider-fill'; @@ -63,10 +64,18 @@ export type { SliderThumbProps } from './ui/slider/slider-thumb'; export type { SliderThumbnailProps } from './ui/slider/slider-thumbnail'; export type { SliderTrackProps } from './ui/slider/slider-track'; export type { SliderValueProps } from './ui/slider/slider-value'; +export { StatusAnnouncer, type StatusAnnouncerProps } from './ui/status-announcer/status-announcer'; +export { StatusIndicator } from './ui/status-indicator'; +export type { StatusIndicatorRootProps } from './ui/status-indicator/status-indicator-root'; +export type { StatusIndicatorValueProps } from './ui/status-indicator/status-indicator-value'; export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; export { Time } from './ui/time'; export { TimeSlider } from './ui/time-slider'; export { Tooltip, type TooltipContextValue, useTooltipContext } from './ui/tooltip'; +export { VolumeIndicator } from './ui/volume-indicator'; +export type { VolumeIndicatorFillProps } from './ui/volume-indicator/volume-indicator-fill'; +export type { VolumeIndicatorRootProps } from './ui/volume-indicator/volume-indicator-root'; +export type { VolumeIndicatorValueProps } from './ui/volume-indicator/volume-indicator-value'; export { VolumeSlider } from './ui/volume-slider'; // Utilities export { mergeProps } from './utils/merge-props'; diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 881c24c2..4cca51a2 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -3,7 +3,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, SeekIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; -import { Hotkey } from '@/ui/hotkey/hotkey'; +import { Hotkey } from '@/ui/hotkey'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; diff --git a/packages/react/src/presets/live-audio/skin.tsx b/packages/react/src/presets/live-audio/skin.tsx index 3fa5496b..2ff71639 100644 --- a/packages/react/src/presets/live-audio/skin.tsx +++ b/packages/react/src/presets/live-audio/skin.tsx @@ -3,7 +3,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; -import { Hotkey } from '@/ui/hotkey/hotkey'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index 7920705e..f868b5b4 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -7,6 +7,7 @@ import { error, icon, iconState, + inputFeedback, overlay, popup, poster, @@ -40,17 +41,25 @@ import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; +import { Gesture } from '@/ui/gesture'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; +import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; +import { VolumeIndicator } from '@/ui/volume-indicator'; import { VolumeSlider } from '@/ui/volume-slider'; import { isRenderProp } from '@/utils/use-render'; import type { MinimalLiveVideoSkinProps } from './minimal-skin'; +const TOP_STATUS_ACTIONS = ['toggleSubtitles', 'toggleFullscreen', 'togglePictureInPicture'] as const; +const CENTER_STATUS_ACTIONS = ['togglePaused'] as const; + const Button = forwardRef>(function Button({ className, ...props }, ref) { return ( @@ -238,6 +247,57 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps):
+ + {/* Hotkeys */} + + + + + + + + + + {/* Gestures */} + + + + + {/* Input Feedback */} + +
+ + + + + + ); } diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index bb8da3bd..034fb762 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -25,17 +25,25 @@ import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; +import { Gesture } from '@/ui/gesture'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; +import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; +import { VolumeIndicator } from '@/ui/volume-indicator'; import { VolumeSlider } from '@/ui/volume-slider'; import { isRenderProp } from '@/utils/use-render'; import type { BaseVideoSkinProps } from '../types'; +const TOP_STATUS_ACTIONS = ['toggleSubtitles', 'toggleFullscreen', 'togglePictureInPicture'] as const; +const CENTER_STATUS_ACTIONS = ['togglePaused'] as const; + export type MinimalLiveVideoSkinProps = BaseVideoSkinProps; const Button = forwardRef>(function Button({ className, ...props }, ref) { @@ -193,6 +201,55 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod
+ + {/* Hotkeys */} + + + + + + + + + + {/* Gestures */} + + + + + {/* Input Feedback */} + +
+ + + + + + ); } diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index fc44df19..98058eca 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -7,6 +7,7 @@ import { error, icon, iconState, + inputFeedback, overlay, popup, poster, @@ -40,17 +41,25 @@ import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; +import { Gesture } from '@/ui/gesture'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; +import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; +import { VolumeIndicator } from '@/ui/volume-indicator'; import { VolumeSlider } from '@/ui/volume-slider'; import { isRenderProp } from '@/utils/use-render'; import type { LiveVideoSkinProps } from './skin'; +const TOP_STATUS_ACTIONS = ['toggleSubtitles', 'toggleFullscreen', 'togglePictureInPicture'] as const; +const CENTER_STATUS_ACTIONS = ['togglePaused'] as const; + const Button = forwardRef>(function Button({ className, ...props }, ref) { return ( + ); +} + +function renderWithPlayer(ui: ReactNode) { + const container = document.createElement('div'); + const playerContextValue = { + store: { + state: {}, + subscribe: () => () => {}, + }, + media: null, + setMedia: vi.fn(), + container, + setContainer: vi.fn(), + } as unknown as PlayerContextValue; + + return render({ui}); +} diff --git a/packages/react/src/ui/input-indicators/use-indicator-visibility.ts b/packages/react/src/ui/input-indicators/use-indicator-visibility.ts new file mode 100644 index 00000000..d1c4b755 --- /dev/null +++ b/packages/react/src/ui/input-indicators/use-indicator-visibility.ts @@ -0,0 +1,30 @@ +'use client'; + +import type { IndicatorVisibilityHandle } from '@videojs/core'; +import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useContainer } from '../../player/context'; +import { useLatestRef } from '../../utils/use-latest-ref'; + +export function useIndicatorVisibility(close: () => void): () => void { + const container = useContainer(); + const closeRef = useLatestRef(close); + const coordinatorRef = useRef | null>(null); + const [handle] = useState(() => ({ + close: () => closeRef.current(), + })); + + useEffect(() => { + if (!container) return; + + const coordinator = getIndicatorVisibilityCoordinator(container); + coordinatorRef.current = coordinator; + + return coordinator.register(handle); + }, [container, handle]); + + return useCallback(() => { + coordinatorRef.current?.show(handle); + }, [handle]); +} diff --git a/packages/react/src/ui/input-indicators/use-input-action-subscription.ts b/packages/react/src/ui/input-indicators/use-input-action-subscription.ts new file mode 100644 index 00000000..c52c4ff6 --- /dev/null +++ b/packages/react/src/ui/input-indicators/use-input-action-subscription.ts @@ -0,0 +1,23 @@ +'use client'; + +import type { InputActionEvent, MediaSnapshot } from '@videojs/core'; +import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom'; +import { useEffect } from 'react'; + +import { useContainer, usePlayer } from '../../player/context'; +import { useLatestRef } from '../../utils/use-latest-ref'; + +export function useInputActionSubscription(callback: (event: InputActionEvent, snapshot: MediaSnapshot) => void): void { + const container = useContainer(); + const store = usePlayer(); + const callbackRef = useLatestRef(callback); + const storeRef = useLatestRef(store); + + useEffect(() => { + if (!container) return; + + return subscribeToInputActions(container, (event) => { + callbackRef.current(event, getMediaSnapshot(storeRef.current)); + }); + }, [container]); +} diff --git a/packages/react/src/ui/input-indicators/use-input-indicator-root.ts b/packages/react/src/ui/input-indicators/use-input-indicator-root.ts new file mode 100644 index 00000000..70a99048 --- /dev/null +++ b/packages/react/src/ui/input-indicators/use-input-indicator-root.ts @@ -0,0 +1,40 @@ +'use client'; + +import type { IndicatorLifecycleState, InputActionEvent, MediaSnapshot } from '@videojs/core'; +import type { State as StoreState } from '@videojs/store'; +import { useState, useSyncExternalStore } from 'react'; + +import { useDestroy } from '../../utils/use-destroy'; +import { useIndicatorVisibility } from './use-indicator-visibility'; +import { useInputActionSubscription } from './use-input-action-subscription'; +import { useRenderedIndicatorState } from './use-rendered-indicator-state'; + +interface InputIndicatorRootCore { + readonly state: StoreState; + setProps(props: Props): void; + destroy(): void; + close(): void; + processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean; +} + +export function useInputIndicatorRoot( + createCore: () => InputIndicatorRootCore, + props: Props +) { + const [core] = useState(createCore); + useDestroy(core); + core.setProps(props); + const showIndicator = useIndicatorVisibility(() => core.close()); + + useInputActionSubscription((event, snapshot) => { + if (core.processEvent(event, snapshot)) showIndicator(); + }); + + const currentState = useSyncExternalStore( + (callback) => core.state.subscribe(callback), + () => core.state.current, + () => core.state.current + ); + + return useRenderedIndicatorState(currentState); +} diff --git a/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts b/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts new file mode 100644 index 00000000..caab2f52 --- /dev/null +++ b/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts @@ -0,0 +1,46 @@ +'use client'; + +import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core'; +import { createTransition } from '@videojs/core/dom'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import { useDestroy } from '../../utils/use-destroy'; + +export function useRenderedIndicatorState(currentState: State) { + const elementRef = useRef(null); + const currentStateRef = useRef(currentState); + const snapshotRef = useRef(currentState); + const [transition] = useState(() => createTransition()); + useDestroy(transition); + currentStateRef.current = currentState; + + const transitionState = useSyncExternalStore( + (callback) => transition.state.subscribe(callback), + () => transition.state.current, + () => transition.state.current + ); + + const { generation, open } = currentState; + + useEffect(() => { + if (open) { + const nextState = currentStateRef.current; + if (nextState.generation !== generation) return; + + snapshotRef.current = nextState; + void transition.open(); + return; + } + + const { active, status } = transition.state.current; + if (active && status !== 'ending') { + void transition.close(elementRef.current); + } + }, [generation, open, transition]); + + return { + elementRef, + present: isIndicatorPresent(currentState, transitionState), + state: getRenderedIndicatorState(currentState, snapshotRef.current, transitionState), + }; +} diff --git a/packages/react/src/ui/seek-indicator/context.tsx b/packages/react/src/ui/seek-indicator/context.tsx new file mode 100644 index 00000000..c0cf6f2d --- /dev/null +++ b/packages/react/src/ui/seek-indicator/context.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { SeekIndicatorCore } from '@videojs/core'; +import { createContext, type ProviderProps, useContext } from 'react'; + +export interface SeekIndicatorContextValue { + state: SeekIndicatorCore.State; +} + +const SeekIndicatorContext = createContext(null); + +export function SeekIndicatorProvider({ value, children }: ProviderProps) { + return {children}; +} + +export function useSeekIndicatorContext(): SeekIndicatorContextValue { + const ctx = useContext(SeekIndicatorContext); + if (!ctx) throw new Error('SeekIndicator child compounds must be used within a SeekIndicator.Root'); + return ctx; +} diff --git a/packages/react/src/ui/seek-indicator/index.parts.ts b/packages/react/src/ui/seek-indicator/index.parts.ts new file mode 100644 index 00000000..93d1fad9 --- /dev/null +++ b/packages/react/src/ui/seek-indicator/index.parts.ts @@ -0,0 +1,2 @@ +export { SeekIndicatorRoot as Root, type SeekIndicatorRootProps as RootProps } from './seek-indicator-root'; +export { SeekIndicatorValue as Value, type SeekIndicatorValueProps as ValueProps } from './seek-indicator-value'; diff --git a/packages/react/src/ui/seek-indicator/index.ts b/packages/react/src/ui/seek-indicator/index.ts new file mode 100644 index 00000000..b08708ae --- /dev/null +++ b/packages/react/src/ui/seek-indicator/index.ts @@ -0,0 +1 @@ +export * as SeekIndicator from './index.parts'; diff --git a/packages/react/src/ui/seek-indicator/seek-indicator-root.tsx b/packages/react/src/ui/seek-indicator/seek-indicator-root.tsx new file mode 100644 index 00000000..24ae50cb --- /dev/null +++ b/packages/react/src/ui/seek-indicator/seek-indicator-root.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { SeekIndicatorCore, SeekIndicatorDataAttrs } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root'; +import { SeekIndicatorProvider } from './context'; + +export interface SeekIndicatorRootProps + extends UIComponentProps<'div', SeekIndicatorCore.State>, + SeekIndicatorCore.Props {} + +export const SeekIndicatorRoot = forwardRef(function SeekIndicatorRoot( + componentProps: SeekIndicatorRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, closeDelay, ...elementProps } = componentProps; + const { elementRef, present, state } = useInputIndicatorRoot(() => new SeekIndicatorCore(), { closeDelay }); + + if (!present) return null; + + return ( + + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: SeekIndicatorDataAttrs, + ref: [forwardedRef, elementRef], + props: [elementProps], + } + )} + + ); +}); + +export namespace SeekIndicatorRoot { + export type Props = SeekIndicatorRootProps; + export type State = SeekIndicatorCore.State; +} diff --git a/packages/react/src/ui/seek-indicator/seek-indicator-value.tsx b/packages/react/src/ui/seek-indicator/seek-indicator-value.tsx new file mode 100644 index 00000000..47233068 --- /dev/null +++ b/packages/react/src/ui/seek-indicator/seek-indicator-value.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { getSeekIndicatorDisplayValue, type SeekIndicatorCore } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSeekIndicatorContext } from './context'; + +export interface SeekIndicatorValueProps extends UIComponentProps<'div', SeekIndicatorCore.State> {} + +export const SeekIndicatorValue = forwardRef(function SeekIndicatorValue( + componentProps: SeekIndicatorValueProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + const { state } = useSeekIndicatorContext(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + ref: forwardedRef, + props: [{ children: getSeekIndicatorDisplayValue(state) }, elementProps], + } + ); +}); + +export namespace SeekIndicatorValue { + export type Props = SeekIndicatorValueProps; +} diff --git a/packages/react/src/ui/slider/context.tsx b/packages/react/src/ui/slider/context.tsx index 5b8b69e3..4e84b29b 100644 --- a/packages/react/src/ui/slider/context.tsx +++ b/packages/react/src/ui/slider/context.tsx @@ -2,7 +2,7 @@ import type { SliderState, StateAttrMap } from '@videojs/core'; import type { SliderThumbProps } from '@videojs/core/dom'; -import type { RefCallback } from 'react'; +import type { ProviderProps, RefCallback } from 'react'; import { createContext, useContext } from 'react'; export interface SliderContextValue { @@ -18,7 +18,9 @@ export interface SliderContextValue { const SliderContext = createContext(null); -export function SliderProvider({ value, children }: { value: SliderContextValue; children: React.ReactNode }) { +type SliderProviderProps = ProviderProps; + +export function SliderProvider({ value, children }: SliderProviderProps) { return {children}; } diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx new file mode 100644 index 00000000..4f8f6983 --- /dev/null +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { StatusAnnouncerCore } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useState, useSyncExternalStore } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { useDestroy } from '../../utils/use-destroy'; +import { renderElement } from '../../utils/use-render'; +import { useInputActionSubscription } from '../input-indicators/use-input-action-subscription'; + +export interface StatusAnnouncerProps + extends UIComponentProps<'div', StatusAnnouncerCore.State>, + StatusAnnouncerCore.Props {} + +export const StatusAnnouncer = forwardRef(function StatusAnnouncer( + componentProps: StatusAnnouncerProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, closeDelay, labels, ...elementProps } = componentProps; + const [core] = useState(() => new StatusAnnouncerCore()); + useDestroy(core); + core.setProps({ closeDelay, labels }); + + useInputActionSubscription((event, snapshot) => { + core.processEvent(event, snapshot); + }); + + const state = useSyncExternalStore( + (callback) => core.state.subscribe(callback), + () => core.state.current, + () => core.state.current + ); + + return renderElement( + 'div', + { render, className, style }, + { + state, + ref: forwardedRef, + props: [ + { + role: 'status', + 'aria-label': state.label ?? undefined, + }, + elementProps, + ], + } + ); +}); + +export namespace StatusAnnouncer { + export type Props = StatusAnnouncerProps; + export type State = StatusAnnouncerCore.State; +} diff --git a/packages/react/src/ui/status-indicator/context.tsx b/packages/react/src/ui/status-indicator/context.tsx new file mode 100644 index 00000000..e2bbce61 --- /dev/null +++ b/packages/react/src/ui/status-indicator/context.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { StatusIndicatorCore } from '@videojs/core'; +import { createContext, type ProviderProps, useContext } from 'react'; + +export interface StatusIndicatorContextValue { + state: StatusIndicatorCore.State; +} + +const StatusIndicatorContext = createContext(null); + +export function StatusIndicatorProvider({ value, children }: ProviderProps) { + return {children}; +} + +export function useStatusIndicatorContext(): StatusIndicatorContextValue { + const ctx = useContext(StatusIndicatorContext); + if (!ctx) throw new Error('StatusIndicator child compounds must be used within a StatusIndicator.Root'); + return ctx; +} diff --git a/packages/react/src/ui/status-indicator/index.parts.ts b/packages/react/src/ui/status-indicator/index.parts.ts new file mode 100644 index 00000000..160a9c55 --- /dev/null +++ b/packages/react/src/ui/status-indicator/index.parts.ts @@ -0,0 +1,8 @@ +export { + StatusIndicatorRoot as Root, + type StatusIndicatorRootProps as RootProps, +} from './status-indicator-root'; +export { + StatusIndicatorValue as Value, + type StatusIndicatorValueProps as ValueProps, +} from './status-indicator-value'; diff --git a/packages/react/src/ui/status-indicator/index.ts b/packages/react/src/ui/status-indicator/index.ts new file mode 100644 index 00000000..f6cc8e4e --- /dev/null +++ b/packages/react/src/ui/status-indicator/index.ts @@ -0,0 +1 @@ +export * as StatusIndicator from './index.parts'; diff --git a/packages/react/src/ui/status-indicator/status-indicator-root.tsx b/packages/react/src/ui/status-indicator/status-indicator-root.tsx new file mode 100644 index 00000000..cb450c10 --- /dev/null +++ b/packages/react/src/ui/status-indicator/status-indicator-root.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { StatusIndicatorCore, StatusIndicatorDataAttrs } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root'; +import { StatusIndicatorProvider } from './context'; + +export interface StatusIndicatorRootProps + extends UIComponentProps<'div', StatusIndicatorCore.State>, + StatusIndicatorCore.Props {} + +export const StatusIndicatorRoot = forwardRef(function StatusIndicatorRoot( + componentProps: StatusIndicatorRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, actions, closeDelay, labels, ...elementProps } = componentProps; + const { elementRef, present, state } = useInputIndicatorRoot(() => new StatusIndicatorCore(), { + actions, + closeDelay, + labels, + }); + + if (!present) return null; + + return ( + + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: StatusIndicatorDataAttrs, + ref: [forwardedRef, elementRef], + props: [elementProps], + } + )} + + ); +}); + +export namespace StatusIndicatorRoot { + export type Props = StatusIndicatorRootProps; + export type State = StatusIndicatorCore.State; +} diff --git a/packages/react/src/ui/status-indicator/status-indicator-value.tsx b/packages/react/src/ui/status-indicator/status-indicator-value.tsx new file mode 100644 index 00000000..84f365df --- /dev/null +++ b/packages/react/src/ui/status-indicator/status-indicator-value.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { getStatusIndicatorDisplayValue, type StatusIndicatorCore } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useStatusIndicatorContext } from './context'; + +export interface StatusIndicatorValueProps extends UIComponentProps<'span', StatusIndicatorCore.State> {} + +export const StatusIndicatorValue = forwardRef(function StatusIndicatorValue( + componentProps: StatusIndicatorValueProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + const { state } = useStatusIndicatorContext(); + + return renderElement( + 'span', + { render, className, style }, + { + state, + ref: forwardedRef, + props: [{ children: getStatusIndicatorDisplayValue(state) }, elementProps], + } + ); +}); + +export namespace StatusIndicatorValue { + export type Props = StatusIndicatorValueProps; +} diff --git a/packages/react/src/ui/volume-indicator/context.tsx b/packages/react/src/ui/volume-indicator/context.tsx new file mode 100644 index 00000000..5207181d --- /dev/null +++ b/packages/react/src/ui/volume-indicator/context.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { VolumeIndicatorCore } from '@videojs/core'; +import { createContext, type ProviderProps, useContext } from 'react'; + +export interface VolumeIndicatorContextValue { + state: VolumeIndicatorCore.State; +} + +const VolumeIndicatorContext = createContext(null); + +export function VolumeIndicatorProvider({ value, children }: ProviderProps) { + return {children}; +} + +export function useVolumeIndicatorContext(): VolumeIndicatorContextValue { + const ctx = useContext(VolumeIndicatorContext); + if (!ctx) throw new Error('VolumeIndicator child compounds must be used within a VolumeIndicator.Root'); + return ctx; +} diff --git a/packages/react/src/ui/volume-indicator/index.parts.ts b/packages/react/src/ui/volume-indicator/index.parts.ts new file mode 100644 index 00000000..bb57ba7b --- /dev/null +++ b/packages/react/src/ui/volume-indicator/index.parts.ts @@ -0,0 +1,6 @@ +export { VolumeIndicatorFill as Fill, type VolumeIndicatorFillProps as FillProps } from './volume-indicator-fill'; +export { VolumeIndicatorRoot as Root, type VolumeIndicatorRootProps as RootProps } from './volume-indicator-root'; +export { + VolumeIndicatorValue as Value, + type VolumeIndicatorValueProps as ValueProps, +} from './volume-indicator-value'; diff --git a/packages/react/src/ui/volume-indicator/index.ts b/packages/react/src/ui/volume-indicator/index.ts new file mode 100644 index 00000000..73d4f6a3 --- /dev/null +++ b/packages/react/src/ui/volume-indicator/index.ts @@ -0,0 +1 @@ +export * as VolumeIndicator from './index.parts'; diff --git a/packages/react/src/ui/volume-indicator/volume-indicator-fill.tsx b/packages/react/src/ui/volume-indicator/volume-indicator-fill.tsx new file mode 100644 index 00000000..0a3b2b3e --- /dev/null +++ b/packages/react/src/ui/volume-indicator/volume-indicator-fill.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { type VolumeIndicatorCore, VolumeIndicatorCSSVars } from '@videojs/core'; +import { isFunction } from '@videojs/utils/predicate'; +import type { CSSProperties, ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useVolumeIndicatorContext } from './context'; + +export interface VolumeIndicatorFillProps extends UIComponentProps<'div', VolumeIndicatorCore.State> {} + +export const VolumeIndicatorFill = forwardRef(function VolumeIndicatorFill( + componentProps: VolumeIndicatorFillProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + const { state } = useVolumeIndicatorContext(); + const fillStyle = getVolumeIndicatorFillStyle(state, style); + + return renderElement( + 'div', + { render, className, style: fillStyle }, + { + state, + ref: forwardedRef, + props: [elementProps], + } + ); +}); + +export namespace VolumeIndicatorFill { + export type Props = VolumeIndicatorFillProps; +} + +function getVolumeIndicatorFillStyle( + state: VolumeIndicatorCore.State, + style: VolumeIndicatorFillProps['style'] +): VolumeIndicatorFillProps['style'] { + const vars = state.fill + ? ({ + [VolumeIndicatorCSSVars.fill]: state.fill, + } as CSSProperties) + : undefined; + + if (!vars) return style; + + if (isFunction(style)) { + return (nextState) => ({ + ...style(nextState), + ...vars, + }); + } + + return { + ...style, + ...vars, + }; +} diff --git a/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx b/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx new file mode 100644 index 00000000..bd12a4de --- /dev/null +++ b/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { VolumeIndicatorCore, VolumeIndicatorDataAttrs } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root'; +import { VolumeIndicatorProvider } from './context'; + +export interface VolumeIndicatorRootProps + extends UIComponentProps<'div', VolumeIndicatorCore.State>, + VolumeIndicatorCore.Props {} + +export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot( + componentProps: VolumeIndicatorRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, closeDelay, ...elementProps } = componentProps; + const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay }); + + if (!present) return null; + + return ( + + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: VolumeIndicatorDataAttrs, + ref: [forwardedRef, elementRef], + props: [elementProps], + } + )} + + ); +}); + +export namespace VolumeIndicatorRoot { + export type Props = VolumeIndicatorRootProps; + export type State = VolumeIndicatorCore.State; +} diff --git a/packages/react/src/ui/volume-indicator/volume-indicator-value.tsx b/packages/react/src/ui/volume-indicator/volume-indicator-value.tsx new file mode 100644 index 00000000..5084e1bb --- /dev/null +++ b/packages/react/src/ui/volume-indicator/volume-indicator-value.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { getVolumeIndicatorDisplayValue, type VolumeIndicatorCore } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useVolumeIndicatorContext } from './context'; + +export interface VolumeIndicatorValueProps extends UIComponentProps<'span', VolumeIndicatorCore.State> {} + +export const VolumeIndicatorValue = forwardRef(function VolumeIndicatorValue( + componentProps: VolumeIndicatorValueProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + const { state } = useVolumeIndicatorContext(); + + return renderElement( + 'span', + { render, className, style }, + { + state, + ref: forwardedRef, + props: [{ children: getVolumeIndicatorDisplayValue(state) }, elementProps], + } + ); +}); + +export namespace VolumeIndicatorValue { + export type Props = VolumeIndicatorValueProps; +} diff --git a/packages/skins/src/default/css/components/button.css b/packages/skins/src/default/css/components/button.css index 2c477603..3652eff9 100644 --- a/packages/skins/src/default/css/components/button.css +++ b/packages/skins/src/default/css/components/button.css @@ -79,7 +79,12 @@ } & .media-icon { - filter: drop-shadow(0 1px 0 var(--media-controls-current-shadow-color, oklch(0 0 0 / 0.25))); + grid-area: 1 / 1; + transition-behavior: allow-discrete; + transition-property: display, opacity; + transition-duration: 150ms; + transition-timing-function: ease-out; + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); } } diff --git a/packages/skins/src/default/css/components/controls.css b/packages/skins/src/default/css/components/controls.css index 71e3cf4f..c8bcc828 100644 --- a/packages/skins/src/default/css/components/controls.css +++ b/packages/skins/src/default/css/components/controls.css @@ -3,16 +3,11 @@ ========================================================================== */ .media-default-skin .media-controls { - --media-controls-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); - --media-controls-current-shadow-color-subtle: oklch( - from var(--media-controls-current-shadow-color) l c h / - calc(alpha * 0.4) - ); display: flex; column-gap: 0.075rem; align-items: center; padding: 0.375rem; container: media-controls / inline-size; - text-shadow: 0 1px 0 var(--media-controls-current-shadow-color); + text-shadow: 0 1px 0 var(--media-current-shadow-color); border-radius: 1.5rem; } diff --git a/packages/skins/src/default/css/components/icons.css b/packages/skins/src/default/css/components/icons.css index 39b57800..d11bbb28 100644 --- a/packages/skins/src/default/css/components/icons.css +++ b/packages/skins/src/default/css/components/icons.css @@ -6,15 +6,9 @@ position: relative; } .media-default-skin .media-icon { - display: block; flex-shrink: 0; - grid-area: 1 / 1; - width: 18px; - height: 18px; - transition-behavior: allow-discrete; - transition-property: display, opacity; - transition-duration: 150ms; - transition-timing-function: ease-out; + width: var(--media-icon-size); + height: var(--media-icon-size); } .media-default-skin .media-icon--flipped { scale: -1 1; diff --git a/packages/skins/src/default/css/components/input-feedback.css b/packages/skins/src/default/css/components/input-feedback.css new file mode 100644 index 00000000..08f02c5b --- /dev/null +++ b/packages/skins/src/default/css/components/input-feedback.css @@ -0,0 +1,228 @@ +/* ========================================================================== + Input Feedback + ========================================================================== */ + +.media-default-skin .media-input-feedback { + position: absolute; + inset-inline: 0; + top: 0; + bottom: 3.5rem; /* Shift up a little in smaller containers */ + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + justify-items: center; + color: var(--media-color-primary, oklch(1 0 0)); + pointer-events: none; + + @container media-root (width > 24rem) { + bottom: 0; + } +} + +/* --- Feedback islands ------------------------------------------------------- */ + +.media-default-skin .media-input-feedback-island { + --media-surface-background-color: oklch(0 0 0 / 0.25); + position: absolute; + top: 0.75rem; + font-weight: 500; + color: inherit; + pointer-events: none; + border-radius: calc(Infinity * 1px); + transform-origin: top center; + transition-timing-function: ease-out; + transition-duration: 100ms; + + .media-input-feedback-island__content { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.25rem 0.625rem; + + /* Increase contrast of the content */ + * { + mix-blend-mode: difference; + } + } + + .media-icon { + display: none; + flex-shrink: 0; + } + + .media-input-feedback-island__value { + margin-left: auto; + } + + @media (pointer: coarse) { + transition-property: scale, translate, opacity; + will-change: scale, translate, opacity; + } + + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { + transition-property: scale, translate, filter, opacity; + will-change: scale, translate, filter, opacity; + } + + @media (prefers-reduced-transparency: reduce) or (prefers-contrast: more) { + --media-surface-background-color: oklch(0 0 0); + } + + /* Default hidden state */ + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transition-timing-function: ease-in; + transition-duration: 250ms; + + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { + filter: blur(8px); + scale: 0.9; + } + + @media (prefers-reduced-motion: no-preference) { + &[data-ending-style] { + translate: 0 -25%; + } + } + } +} + +.media-default-skin .media-input-feedback-island--volume { + width: min(80%, 12rem); + + .media-input-feedback-island__content { + --media-progress-fill: var(--media-volume-fill); + background-image: linear-gradient( + to right, + currentColor 0%, + currentColor var(--media-progress-fill), + transparent var(--media-progress-fill), + transparent 100% + ); + border-radius: inherit; + transition: --media-progress-fill 200ms linear; + } +} + +.media-default-skin .media-input-feedback-island--volume[data-level="high"] .media-icon--volume-high, +.media-default-skin .media-input-feedback-island--volume[data-level="low"] .media-icon--volume-low, +.media-default-skin .media-input-feedback-island--volume[data-level="off"] .media-icon--volume-off { + display: block; +} + +.media-default-skin .media-input-feedback-island--status[data-status="captions-on"] .media-icon--captions-on, +.media-default-skin .media-input-feedback-island--status[data-status="captions-off"] .media-icon--captions-off, +.media-default-skin .media-input-feedback-island--status[data-status="fullscreen"] .media-icon--fullscreen-enter, +.media-default-skin .media-input-feedback-island--status[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, +.media-default-skin .media-input-feedback-island--status[data-status="pip"] .media-icon--pip-enter, +.media-default-skin .media-input-feedback-island--status[data-status="exit-pip"] .media-icon--pip-exit { + display: block; +} + +/* --- Boundary shake ------------------------------------------------------- */ + +@media (prefers-reduced-motion: no-preference) { + .media-default-skin .media-input-feedback-island--volume[data-min], + .media-default-skin .media-input-feedback-island--volume[data-max] { + animation: media-shake 300ms ease-in-out; + } +} + +/* --- Bubble ---------------------------------------------------------------- */ + +.media-default-skin .media-input-feedback-bubble { + display: flex; + flex-direction: column; + grid-row: 1; + grid-column: 2; /* default to center for status bubbles and undirected seeks */ + align-items: center; + justify-content: center; + padding: 1rem; + transition: opacity 250ms ease-out; + + @container media-root (width > 24rem) { + padding: 2rem; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transition-timing-function: ease-in; + transition-duration: 200ms; + } +} + +/* Direction placement — seek bubbles move to the side implied by their direction. */ +.media-default-skin .media-input-feedback-bubble[data-direction="backward"] { + grid-column: 1; + justify-self: left; +} + +.media-default-skin .media-input-feedback-bubble:not([data-direction]) { + grid-column: 2; + transition-timing-function: + ease-out, linear(0, 0.12 1.5%, 1.35 9.7%, 2.2 13.9%, 3 19.9%, 2.7 21.8%, 0.62 37.5%, 0.96 50.9%, 1); + transition-duration: 600ms; + transition-property: opacity, scale; + + @media (prefers-reduced-motion: reduce) { + transition: opacity 100ms ease-out; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + scale: 0.8; + transition-timing-function: ease-in; + transition-duration: 200ms; + } +} + +.media-default-skin .media-input-feedback-bubble[data-direction="forward"] { + grid-column: 3; + justify-self: right; +} + +/* --- Bubble icons ---------------------------------------------------------- */ + +.media-default-skin .media-input-feedback-bubble .media-icon { + display: none; + width: 36px; + height: 36px; +} + +/* seek: seek icon, flipped for backward */ +.media-default-skin .media-input-feedback-bubble[data-direction] .media-icon--seek { + display: block; +} + +.media-default-skin .media-input-feedback-bubble[data-direction="backward"] .media-icon--seek { + transform: scaleX(-1); +} + +@media (prefers-reduced-motion: no-preference) { + .media-default-skin + .media-input-feedback-bubble[data-direction="forward"]:not([data-starting-style]) + .media-icon--seek { + animation: media-slide-in-forward 300ms ease-in-out; + } + + .media-default-skin + .media-input-feedback-bubble[data-direction="backward"]:not([data-starting-style]) + .media-icon--seek { + animation: media-slide-in-backward 300ms ease-in-out; + } + + .media-default-skin .media-input-feedback-island--status[data-status]:not([data-starting-style]) .media-icon, + .media-default-skin .media-input-feedback-bubble[data-status]:not([data-starting-style]) .media-icon { + animation: media-pop-in 250ms ease-out; + } +} + +.media-default-skin .media-input-feedback-bubble[data-status="pause"] .media-icon--pause, +.media-default-skin .media-input-feedback-bubble[data-status="play"] .media-icon--play { + display: block; +} diff --git a/packages/skins/src/default/css/components/reset.css b/packages/skins/src/default/css/components/reset.css index 99e1bebe..cb3fcbd6 100644 --- a/packages/skins/src/default/css/components/reset.css +++ b/packages/skins/src/default/css/components/reset.css @@ -16,6 +16,10 @@ .media-default-skin button { font: inherit; } +.media-default-skin [hidden][hidden] { + /* Keep authored templates hidden even when component classes set display. */ + display: none; +} @media (prefers-reduced-motion: no-preference) { .media-default-skin { interpolate-size: allow-keywords; diff --git a/packages/skins/src/default/css/components/root.css b/packages/skins/src/default/css/components/root.css index f04a8871..60784d21 100644 --- a/packages/skins/src/default/css/components/root.css +++ b/packages/skins/src/default/css/components/root.css @@ -3,6 +3,9 @@ ========================================================================== */ .media-default-skin { + --media-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); + --media-current-shadow-color-subtle: oklch(from var(--media-current-shadow-color) l c h / calc(alpha * 0.4)); + --media-icon-size: 18px; position: relative; display: block; width: 100%; @@ -19,20 +22,22 @@ line-height: 1.5; letter-spacing: normal; outline: 2px solid transparent; - outline-offset: 2px; + outline-offset: -4px; border-radius: var(--media-border-radius, 2rem); isolation: isolate; + transition-timing-function: ease-out; + transition-duration: 100ms; + transition-property: outline-offset, outline-color; &:focus-visible { outline-color: currentColor; + outline-offset: 2px; } & > * { font-size: 0.75rem; /* 12px at 100% font size */ - } - @container media-root (width > 48rem) { - & > * { + @container media-root (width > 48rem) { font-size: 0.875rem; /* 14px at 100% font size */ } } diff --git a/packages/skins/src/default/css/components/slider.css b/packages/skins/src/default/css/components/slider.css index a2a7e696..f2e18055 100644 --- a/packages/skins/src/default/css/components/slider.css +++ b/packages/skins/src/default/css/components/slider.css @@ -55,7 +55,7 @@ background-color: currentColor; border-radius: calc(infinity * 1px); box-shadow: - 0 0 0 1px var(--media-controls-current-shadow-color-subtle, oklch(0 0 0 / 0.1)), + 0 0 0 1px var(--media-current-shadow-color-subtle, oklch(0 0 0 / 0.1)), 0 1px 3px 0 oklch(0 0 0 / 0.15), 0 1px 2px -1px oklch(0 0 0 / 0.15); opacity: 0; diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css index 78e784ef..bc259810 100644 --- a/packages/skins/src/default/css/video.css +++ b/packages/skins/src/default/css/video.css @@ -15,14 +15,17 @@ @import "./components/slider.css"; @import "./components/popup.css"; @import "./components/captions.css"; +@import "./components/input-feedback.css"; @import "../../shared/css/video/icon-state.css"; +@import "../../shared/global/video/keyframes.css"; +@import "../../shared/global/video/properties.css"; /* ========================================================================== Root ========================================================================== */ .media-default-skin--video { - --media-spring-transition: linear( + --media-spring-timing-function: linear( 0, 0.034 1.5%, 0.763 9.7%, @@ -44,7 +47,7 @@ --media-controls-transition-timing-function: ease-out; --media-error-dialog-transition-duration: 350ms; --media-error-dialog-transition-delay: 100ms; - --media-error-dialog-transition-timing-function: var(--media-spring-transition); + --media-error-dialog-transition-timing-function: var(--media-spring-timing-function); --media-popup-transition-duration: 100ms; --media-popup-transition-timing-function: ease-out; --media-tooltip-side-offset: 0.75rem; diff --git a/packages/skins/src/default/tailwind/audio.tailwind.ts b/packages/skins/src/default/tailwind/audio.tailwind.ts index 938600f4..1682be7c 100644 --- a/packages/skins/src/default/tailwind/audio.tailwind.ts +++ b/packages/skins/src/default/tailwind/audio.tailwind.ts @@ -46,7 +46,7 @@ export const root = cn( Controls ========================================================================== */ -export const controls = cn(baseControls, surface, 'text-(--media-text-color)', 'peer-data-open/error:[&_*]:invisible'); +export const controls = cn(baseControls, surface, 'text-(--media-text-color)', 'peer-data-open/error:**:invisible'); /* ========================================================================== Sliders diff --git a/packages/skins/src/default/tailwind/components/controls.ts b/packages/skins/src/default/tailwind/components/controls.ts index 25092f12..6d85c18f 100644 --- a/packages/skins/src/default/tailwind/components/controls.ts +++ b/packages/skins/src/default/tailwind/components/controls.ts @@ -7,9 +7,6 @@ export const controls = cn( '@container/media-controls', 'p-[0.375rem] flex items-center gap-x-[0.075rem]', 'rounded-3xl', - // Shadow color variables (derived from currentColor lightness) - '[--media-controls-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', - '[--media-controls-current-shadow-color-subtle:oklch(from_var(--media-controls-current-shadow-color)_l_c_h/calc(alpha*0.4))]', // Text shadow - 'text-shadow-2xs text-shadow-(color:--media-controls-current-shadow-color)' + 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)' ); diff --git a/packages/skins/src/default/tailwind/components/icon.ts b/packages/skins/src/default/tailwind/components/icon.ts index 00d3016d..22051a8b 100644 --- a/packages/skins/src/default/tailwind/components/icon.ts +++ b/packages/skins/src/default/tailwind/components/icon.ts @@ -1,8 +1,8 @@ import { cn } from '@videojs/utils/style'; export const icon = cn( - 'block [grid-area:1/1] size-4.5 shrink-0', - 'drop-shadow-[0_1px_0_var(--media-controls-current-shadow-color,oklch(0_0_0/0.25))]', + 'block [grid-area:1/1] size-(--media-icon-size) shrink-0', + 'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]', 'transition-discrete transition-[display,opacity] duration-150 ease-out' ); diff --git a/packages/skins/src/default/tailwind/components/input-feedback.ts b/packages/skins/src/default/tailwind/components/input-feedback.ts new file mode 100644 index 00000000..b2d41bb4 --- /dev/null +++ b/packages/skins/src/default/tailwind/components/input-feedback.ts @@ -0,0 +1,151 @@ +import { cn } from '@videojs/utils/style'; + +/** + * NOTE: tailwind.css is required to support the `@property --media-progress-fill` registration and animation keyframes. You should import from either: +- "@videojs/html/tailwind.css" for HTML skins +- "@videojs/react/tailwind.css" for React skins + */ +export const inputFeedback = { + root: cn( + // Layout + 'absolute inset-x-0 top-0 bottom-14 pointer-events-none', + 'grid grid-cols-3 items-center justify-items-center', + // Shift to full extent in larger containers + '@2xl/media-root:bottom-0', + // Color + '[color:var(--media-color-primary,oklch(1_0_0))]' + ), + + island: { + base: cn( + 'group/input-indicator', + // Surface override (darker than default) + '[--media-surface-background-color:oklch(0_0_0/0.25)]', + // Layout + 'absolute top-3 rounded-full origin-top pointer-events-none', + 'text-inherit font-medium', + // Transition + 'duration-100 ease-out', + 'data-starting-style:opacity-0', + 'data-ending-style:opacity-0', + 'data-starting-style:duration-250', + 'data-starting-style:ease-in', + 'data-ending-style:duration-250', + 'data-ending-style:ease-in', + 'pointer-coarse:will-change-[scale,translate,opacity]', + 'pointer-coarse:transition-[scale,translate,opacity]', + 'pointer-fine:motion-safe:will-change-[scale,translate,filter,opacity]', + 'pointer-fine:motion-safe:transition-[scale,translate,filter,opacity]', + 'pointer-fine:motion-safe:data-starting-style:blur-sm', + 'pointer-fine:motion-safe:data-starting-style:scale-90', + 'pointer-fine:motion-safe:data-ending-style:blur-sm', + 'pointer-fine:motion-safe:data-ending-style:scale-90', + 'motion-safe:data-ending-style:-translate-y-1/4', + // Reduced transparency / high contrast: solid surface background + '[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]', + 'contrast-more:[--media-surface-background-color:oklch(0_0_0)]' + ), + content: cn( + 'flex justify-between items-center gap-2 px-2.5 py-1 w-full', + // Increase contrast of content via blend mode + '**:mix-blend-difference' + ), + // Volume island sizing + progress-fill gradient on the content child + volume: cn( + 'w-[min(80%,12rem)]', + '*:[--media-progress-fill:var(--media-volume-fill)]', + '*:rounded-[inherit]', + '*:[background-image:linear-gradient(to_right,currentColor_0%,currentColor_var(--media-progress-fill),transparent_var(--media-progress-fill),transparent_100%)]', + '*:[transition:--media-progress-fill_200ms_linear]' + ), + // Shown state — applied on the active item itself + shownVolume: cn( + 'data-open:duration-100', + // Boundary shake (keyframes must be registered and media-shake added to @theme — see note at top) + 'data-min:animate-media-shake', + 'data-max:animate-media-shake', + 'motion-reduce:data-min:animate-none', + 'motion-reduce:data-max:animate-none' + ), + shownStatus: cn('data-open:duration-100'), + // Icon inside island — hidden by default; specific icons opt in via shown* below. + icon: cn('hidden shrink-0'), + // Volume level → which icon shows + shownVolumeHigh: 'group-data-[level=high]/input-indicator:block', + shownVolumeLow: 'group-data-[level=low]/input-indicator:block', + shownVolumeOff: 'group-data-[level=off]/input-indicator:block', + // Captions state → which icon shows + shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block', + shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block', + shownFullscreenEnter: cn( + 'group-data-[status=fullscreen]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in' + ), + shownFullscreenExit: cn( + 'group-data-[status=exit-fullscreen]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in' + ), + shownPipEnter: cn( + 'group-data-[status=pip]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in' + ), + shownPipExit: cn( + 'group-data-[status=exit-pip]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in' + ), + value: 'ml-auto', + }, + + bubble: { + base: cn( + 'group/input-indicator', + // Default placement — center column for status bubbles and undirected seeks + 'col-start-2 row-start-1', + 'flex flex-col items-center justify-center p-4', + 'transition-opacity duration-250 ease-out', + 'data-starting-style:opacity-0', + 'data-ending-style:opacity-0', + 'data-starting-style:duration-200', + 'data-starting-style:ease-in', + 'data-ending-style:duration-200', + 'data-ending-style:ease-in', + '@2xl/media-root:p-8', + 'not-data-direction:[transition-property:opacity,scale]', + 'not-data-direction:duration-600', + 'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]', + 'motion-reduce:not-data-direction:transition-opacity', + 'motion-reduce:not-data-direction:duration-100', + 'motion-reduce:not-data-direction:ease-out', + 'not-data-direction:data-starting-style:scale-80', + 'not-data-direction:data-ending-style:scale-80', + 'not-data-direction:data-starting-style:duration-200', + 'not-data-direction:data-starting-style:ease-in', + 'not-data-direction:data-ending-style:duration-200', + 'not-data-direction:data-ending-style:ease-in', + // Direction placement + 'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start', + 'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end' + ), + // Icons in the bubble + icon: 'hidden w-9 h-9', + // seek icon: shown for seekStep + seekToPercent; flipped for backward; slides in on active + shownSeek: cn( + 'group-data-direction/input-indicator:block', + 'group-data-[direction=backward]/input-indicator:-scale-x-100', + // Slide animation (keyframes registered in companion CSS) + 'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward', + 'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward', + 'motion-reduce:group-data-direction/input-indicator:animate-none' + ), + // togglePaused: pause icon when paused, play icon when playing + shownPause: cn( + 'group-data-[status=pause]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in' + ), + shownPlay: cn( + 'group-data-[status=play]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in' + ), + time: 'tabular-nums', + }, +}; diff --git a/packages/skins/src/default/tailwind/components/reset.ts b/packages/skins/src/default/tailwind/components/reset.ts new file mode 100644 index 00000000..4bd4cd5f --- /dev/null +++ b/packages/skins/src/default/tailwind/components/reset.ts @@ -0,0 +1,9 @@ +import { cn } from '@videojs/utils/style'; + +export const reset = cn( + '**:box-border', + // Keep authored templates hidden even when component classes set display. + '[&_[hidden][hidden]]:hidden', + '[&_button]:font-[inherit]', + 'motion-safe:[interpolate-size:allow-keywords]' +); diff --git a/packages/skins/src/default/tailwind/components/root.ts b/packages/skins/src/default/tailwind/components/root.ts index bc354004..2245029f 100644 --- a/packages/skins/src/default/tailwind/components/root.ts +++ b/packages/skins/src/default/tailwind/components/root.ts @@ -1,14 +1,21 @@ import { cn } from '@videojs/utils/style'; +import { reset } from './reset'; export const root = cn( + reset, // Layout & containment 'block relative isolate h-full w-full @container/media-root', // Appearance 'rounded-(--media-border-radius,2rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] leading-normal subpixel-antialiased', - '[&>*]:text-xs @3xl/media-root:[&>*]:text-sm', - // Resets - '**:box-border', - '[&_button]:font-[inherit]', - 'motion-safe:[interpolate-size:allow-keywords]' + '*:text-xs @3xl/media-root:*:text-sm', + // Focus ring + 'outline-2 outline-transparent -outline-offset-4', + 'transition-[outline-offset,outline-color] duration-100 ease-out', + 'focus-visible:outline-current focus-visible:outline-offset-2', + // Shadow color variables (derived from currentColor lightness) + '[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', + '[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]', + // Icon sizing + '[--media-icon-size:18px]' ); diff --git a/packages/skins/src/default/tailwind/components/slider.ts b/packages/skins/src/default/tailwind/components/slider.ts index a72eef39..a6c64c36 100644 --- a/packages/skins/src/default/tailwind/components/slider.ts +++ b/packages/skins/src/default/tailwind/components/slider.ts @@ -40,7 +40,7 @@ export const slider = { base: cn( 'z-10 absolute -translate-x-1/2 -translate-y-1/2', 'bg-current rounded-full', - 'shadow-[0_0_0_1px_var(--media-controls-current-shadow-color-subtle,oklch(0_0_0/0.1)),0_1px_3px_0_oklch(0_0_0/0.15),0_1px_2px_-1px_oklch(0_0_0/0.15)]', + 'shadow-[0_0_0_1px_var(--media-current-shadow-color-subtle,oklch(0_0_0/0.1)),0_1px_3px_0_oklch(0_0_0/0.15),0_1px_2px_-1px_oklch(0_0_0/0.15)]', 'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none', 'outline-4 outline-transparent -outline-offset-4', 'hover:outline-current/25 hover:outline-offset-0', diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index e54ee648..8128ac0c 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -3,6 +3,7 @@ import { bufferingIndicator as baseBufferingIndicator } from './components/buffe import { buttonGroup as baseButtonGroup } from './components/button-group'; import { controls as baseControls } from './components/controls'; import { error as baseError } from './components/error'; +import { inputFeedback as baseInputFeedback } from './components/input-feedback'; import { popup as basePopup } from './components/popup'; import { preview as basePreview } from './components/preview'; import { root as baseRoot } from './components/root'; @@ -28,13 +29,13 @@ export const root = (isShadowDOM: boolean) => '[&_video]:block [&_video]:w-full [&_video]:h-full [&_video]:rounded-[inherit] [&_video]:[object-fit:var(--media-object-fit,contain)] [&_video]:[object-position:var(--media-object-position,center)]': !isShadowDOM, }, - '[--media-spring-transition:linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]', + '[--media-spring-timing-function:linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]', '[--media-video-border-radius:var(--media-border-radius,2rem)]', '[--media-controls-transition-duration:100ms]', '[--media-controls-transition-timing-function:ease-out]', '[--media-error-dialog-transition-duration:350ms]', '[--media-error-dialog-transition-delay:100ms]', - '[--media-error-dialog-transition-timing-function:var(--media-spring-transition)]', + '[--media-error-dialog-transition-timing-function:var(--media-spring-timing-function)]', '[--media-popup-transition-duration:100ms]', '[--media-popup-transition-timing-function:ease-out]', '[--media-surface-background-color:oklch(1_0_0/0.1)]', @@ -54,15 +55,15 @@ export const root = (isShadowDOM: boolean) => 'contrast-more:[--media-surface-inner-border-color:oklch(1_0_0/0.25)]', '[@media(prefers-reduced-transparency:reduce)]:[--media-surface-outer-border-color:transparent]', 'contrast-more:[--media-surface-outer-border-color:transparent]', - '[@media(pointer:fine)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]', - '[@media(pointer:coarse)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]', + 'pointer-fine:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]', + 'pointer-coarse:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]', 'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:50ms]', // Caption track CSS variables (consumed by the native caption bridge in light DOM) '[--media-caption-track-y:-0.5rem]', '[--media-caption-track-delay:25ms]', '[--media-caption-track-duration:var(--media-controls-transition-duration)]', 'has-[[data-controls][data-visible]]:[--media-caption-track-y:-5.5rem]', - '@2xl/media-root:has-[[data-controls][data-visible]]:[&>*]:[--media-caption-track-y:-3.5rem]', + '@2xl/media-root:has-[[data-controls][data-visible]]:*:[--media-caption-track-y:-3.5rem]', // Native caption track container !isShadowDOM ? [ @@ -97,14 +98,14 @@ export const controls = cn( 'peer-data-open/error:hidden', 'ease-(--media-controls-transition-timing-function) origin-bottom', 'duration-(--media-controls-transition-duration)', - '[@media(pointer:fine)]:will-change-[scale,filter,opacity]', - '[@media(pointer:fine)]:transition-[scale,filter,opacity]', - '[@media(pointer:coarse)]:will-change-[scale,opacity]', - '[@media(pointer:coarse)]:transition-[scale,opacity]', + 'pointer-fine:will-change-[scale,filter,opacity]', + 'pointer-fine:transition-[scale,filter,opacity]', + 'pointer-coarse:will-change-[scale,opacity]', + 'pointer-coarse:transition-[scale,opacity]', // Hidden state 'not-data-visible:pointer-events-none not-data-visible:opacity-0', 'motion-safe:not-data-visible:scale-90', - '[@media(pointer:fine)]:motion-safe:not-data-visible:blur-sm', + 'pointer-fine:motion-safe:not-data-visible:blur-sm', // Single-row layout (large) '@2xl/media-root:bottom-3 @2xl/media-root:inset-x-3 @2xl/media-root:flex-nowrap @2xl/media-root:gap-x-0.5 @2xl/media-root:p-1' ); @@ -141,8 +142,8 @@ export const preview = { 'opacity-0 scale-80 blur-sm origin-bottom', 'transition-[scale,opacity,filter] duration-150', 'group-data-pointing/slider:opacity-100 group-data-pointing/slider:scale-100 group-data-pointing/slider:blur-none', - '[&:has([role=img][data-hidden])]:opacity-0 [&:has([role=img][data-hidden])]:scale-80 [&:has([role=img][data-hidden])]:blur-sm', - '[&:has([role=img][data-loading])]:max-h-24', + 'has-[[role=img][data-hidden]]:opacity-0 has-[[role=img][data-hidden]]:scale-80 has-[[role=img][data-hidden]]:blur-sm', + 'has-[[role=img][data-loading]]:max-h-24', surface, basePreview.root ), @@ -188,6 +189,18 @@ export const error = { title: cn(baseError.title, 'text-base'), }; +/* ========================================================================== + Input Feedback (islands use video surface) + ========================================================================== */ + +export const inputFeedback = { + ...baseInputFeedback, + island: { + ...baseInputFeedback.island, + base: cn(baseInputFeedback.island.base, surface), + }, +}; + /* ========================================================================== Shared components (no overrides) ========================================================================== */ diff --git a/packages/skins/src/minimal/css/components/button.css b/packages/skins/src/minimal/css/components/button.css index 49b8a2cd..84328b2b 100644 --- a/packages/skins/src/minimal/css/components/button.css +++ b/packages/skins/src/minimal/css/components/button.css @@ -87,7 +87,12 @@ } & .media-icon { - filter: drop-shadow(0 1px 0 var(--media-controls-current-shadow-color, oklch(0 0 0 / 0.25))); + grid-area: 1 / 1; + transition-behavior: allow-discrete; + transition-property: display, opacity; + transition-duration: 150ms; + transition-timing-function: ease-out; + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); } } diff --git a/packages/skins/src/minimal/css/components/controls.css b/packages/skins/src/minimal/css/components/controls.css index 897e607f..3e5d7e72 100644 --- a/packages/skins/src/minimal/css/components/controls.css +++ b/packages/skins/src/minimal/css/components/controls.css @@ -3,15 +3,10 @@ ========================================================================== */ .media-minimal-skin .media-controls { - --media-controls-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); - --media-controls-current-shadow-color-subtle: oklch( - from var(--media-controls-current-shadow-color) l c h / - calc(alpha * 0.4) - ); display: flex; align-items: center; container: media-controls / inline-size; - text-shadow: 0 1px 0 var(--media-controls-current-shadow-color); + text-shadow: 0 1px 0 var(--media-current-shadow-color); background-color: var(--media-controls-background-color); backdrop-filter: var(--media-controls-backdrop-filter); } diff --git a/packages/skins/src/minimal/css/components/icons.css b/packages/skins/src/minimal/css/components/icons.css index b3dcc65b..6c56b4bb 100644 --- a/packages/skins/src/minimal/css/components/icons.css +++ b/packages/skins/src/minimal/css/components/icons.css @@ -6,15 +6,9 @@ position: relative; } .media-minimal-skin .media-icon { - display: block; flex-shrink: 0; - grid-area: 1 / 1; - width: 18px; - height: 18px; - transition-behavior: allow-discrete; - transition-property: display, opacity; - transition-duration: 150ms; - transition-timing-function: ease-out; + width: var(--media-icon-size); + height: var(--media-icon-size); } .media-minimal-skin .media-icon--flipped { scale: -1 1; diff --git a/packages/skins/src/minimal/css/components/input-feedback.css b/packages/skins/src/minimal/css/components/input-feedback.css new file mode 100644 index 00000000..1c48ab8d --- /dev/null +++ b/packages/skins/src/minimal/css/components/input-feedback.css @@ -0,0 +1,238 @@ +/* ========================================================================== + Input Feedback + ========================================================================== */ + +.media-minimal-skin .media-input-feedback { + position: absolute; + inset-inline: 0; + top: 0; + bottom: 3.5rem; /* Shift up a little in smaller containers */ + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + justify-items: center; + overflow: hidden; + color: var(--media-color-primary, oklch(1 0 0)); + pointer-events: none; + border-radius: inherit; + + @container media-root (width > 24rem) { + bottom: 0; + } +} + +/* --- Feedback islands ------------------------------------------------------- */ + +.media-minimal-skin .media-input-feedback-island { + position: absolute; + inset-inline: 0; + top: 0; + display: flex; + justify-content: center; + padding-top: 0.75rem; + padding-bottom: 8rem; + color: inherit; + text-shadow: 0 1px 0 var(--media-current-shadow-color); + pointer-events: none; + background-image: linear-gradient(to bottom, oklch(0 0 0 / 0.35), oklch(0 0 0 / 0.2) 3rem, oklch(0 0 0 / 0)); + transform-origin: top center; + transition-timing-function: ease-out; + transition-duration: 100ms; + + .media-input-feedback-island__content { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + padding: 0.25rem 0.625rem; + } + + .media-icon { + display: none; + flex-shrink: 0; + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); + } + + .media-input-feedback-island__value { + margin-left: auto; + } + + @media (pointer: fine) { + transition-property: translate, filter, opacity; + will-change: translate, filter, opacity; + } + + @media (pointer: coarse) { + transition-property: translate, opacity; + will-change: translate, opacity; + } + + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { + transition-property: translate, filter, opacity; + } + + @media (prefers-reduced-transparency: reduce) or (prefers-contrast: more) { + .media-input-feedback-island__content { + background: var(--media-controls-background-color); + border-radius: 0.5rem; + } + } + + /* Default hidden state */ + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transition-timing-function: ease-in; + transition-duration: 400ms; + + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { + filter: blur(8px); + } + + @media (prefers-reduced-motion: no-preference) { + &[data-ending-style] { + translate: 0 -100%; + } + } + } +} + +.media-minimal-skin .media-input-feedback-island--volume { + .media-input-feedback-island__content { + width: min(80%, 14rem); + } + + .media-input-feedback-island__progress { + --media-progress-fill: var(--media-volume-fill); + width: 100%; + height: 0.1875rem; + background-image: linear-gradient( + to right, + currentColor 0%, + currentColor var(--media-progress-fill), + oklch(from currentColor l c h / 0.2) var(--media-progress-fill), + oklch(from currentColor l c h / 0.2) 100% + ); + border-radius: calc(Infinity * 1px); + box-shadow: 0 1px 0 var(--media-current-shadow-color-subtle); + } +} + +.media-minimal-skin .media-input-feedback-island--volume[data-level="high"] .media-icon--volume-high, +.media-minimal-skin .media-input-feedback-island--volume[data-level="low"] .media-icon--volume-low, +.media-minimal-skin .media-input-feedback-island--volume[data-level="off"] .media-icon--volume-off { + display: block; +} + +.media-minimal-skin .media-input-feedback-island--status[data-status="captions-on"] .media-icon--captions-on, +.media-minimal-skin .media-input-feedback-island--status[data-status="captions-off"] .media-icon--captions-off, +.media-minimal-skin .media-input-feedback-island--status[data-status="fullscreen"] .media-icon--fullscreen-enter, +.media-minimal-skin .media-input-feedback-island--status[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, +.media-minimal-skin .media-input-feedback-island--status[data-status="pip"] .media-icon--pip-enter, +.media-minimal-skin .media-input-feedback-island--status[data-status="exit-pip"] .media-icon--pip-exit { + display: block; +} + +/* --- Boundary shake ------------------------------------------------------- */ + +@media (prefers-reduced-motion: no-preference) { + .media-minimal-skin .media-input-feedback-island--volume[data-min] .media-input-feedback-island__content, + .media-minimal-skin .media-input-feedback-island--volume[data-max] .media-input-feedback-island__content { + animation: media-shake 300ms ease-in-out; + } +} + +/* --- Bubble ---------------------------------------------------------------- */ + +.media-minimal-skin .media-input-feedback-bubble { + display: flex; + flex-direction: column; + grid-row: 1; + grid-column: 2; /* default to center for status bubbles and undirected seeks */ + align-items: center; + justify-content: center; + padding: 1rem; + transition: opacity 250ms ease-out; + + @container media-root (width > 24rem) { + padding: 2rem; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transition-timing-function: ease-in; + transition-duration: 200ms; + } +} +/* Direction placement — seek bubbles move to the side implied by their direction. */ +.media-minimal-skin .media-input-feedback-bubble[data-direction="backward"] { + grid-column: 1; + justify-self: left; +} + +.media-minimal-skin .media-input-feedback-bubble:not([data-direction]) { + grid-column: 2; + transition-timing-function: + ease-out, linear(0, 0.12 1.5%, 1.35 9.7%, 2.2 13.9%, 3 19.9%, 2.7 21.8%, 0.62 37.5%, 0.96 50.9%, 1); + transition-duration: 600ms; + transition-property: opacity, scale; + + @media (prefers-reduced-motion: reduce) { + transition: opacity 100ms ease-out; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + scale: 0.8; + transition-timing-function: ease-in; + transition-duration: 200ms; + } +} + +.media-minimal-skin .media-input-feedback-bubble[data-direction="forward"] { + grid-column: 3; + justify-self: right; +} + +/* --- Bubble icons ---------------------------------------------------------- */ + +.media-minimal-skin .media-input-feedback-bubble .media-icon { + display: none; + width: 36px; + height: 36px; +} + +/* seek: seek icon, flipped for backward */ +.media-minimal-skin .media-input-feedback-bubble[data-direction] .media-icon--seek { + display: block; +} + +.media-minimal-skin .media-input-feedback-bubble[data-direction="backward"] .media-icon--seek { + transform: scaleX(-1); +} + +@media (prefers-reduced-motion: no-preference) { + .media-minimal-skin + .media-input-feedback-bubble[data-direction="forward"]:not([data-starting-style]) + .media-icon--seek { + animation: media-slide-in-forward 300ms ease-in-out; + } + + .media-minimal-skin + .media-input-feedback-bubble[data-direction="backward"]:not([data-starting-style]) + .media-icon--seek { + animation: media-slide-in-backward 300ms ease-in-out; + } + + .media-minimal-skin .media-input-feedback-island--status[data-status]:not([data-starting-style]) .media-icon, + .media-minimal-skin .media-input-feedback-bubble[data-status]:not([data-starting-style]) .media-icon { + animation: media-pop-in 250ms ease-out; + } +} + +.media-minimal-skin .media-input-feedback-bubble[data-status="pause"] .media-icon--pause, +.media-minimal-skin .media-input-feedback-bubble[data-status="play"] .media-icon--play { + display: block; +} diff --git a/packages/skins/src/minimal/css/components/reset.css b/packages/skins/src/minimal/css/components/reset.css index 95ae6b76..6773da60 100644 --- a/packages/skins/src/minimal/css/components/reset.css +++ b/packages/skins/src/minimal/css/components/reset.css @@ -16,6 +16,10 @@ .media-minimal-skin button { font: inherit; } +.media-minimal-skin [hidden][hidden] { + /* Keep authored templates hidden even when component classes set display. */ + display: none; +} @media (prefers-reduced-motion: no-preference) { .media-minimal-skin { interpolate-size: allow-keywords; diff --git a/packages/skins/src/minimal/css/components/root.css b/packages/skins/src/minimal/css/components/root.css index b3675e7d..ddcfd334 100644 --- a/packages/skins/src/minimal/css/components/root.css +++ b/packages/skins/src/minimal/css/components/root.css @@ -3,6 +3,9 @@ ========================================================================== */ .media-minimal-skin { + --media-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); + --media-current-shadow-color-subtle: oklch(from var(--media-current-shadow-color) l c h / calc(alpha * 0.4)); + --media-icon-size: 18px; position: relative; display: block; width: 100%; @@ -19,20 +22,22 @@ line-height: 1.5; letter-spacing: normal; outline: 2px solid transparent; - outline-offset: 2px; + outline-offset: -4px; border-radius: var(--media-border-radius, 0.75rem); isolation: isolate; + transition-timing-function: ease-out; + transition-duration: 100ms; + transition-property: outline-offset, outline-color; &:focus-visible { outline-color: currentColor; + outline-offset: 2px; } & > * { font-size: 0.75rem; /* 12px at 100% font size */ - } - @container media-root (width > 48rem) { - & > * { + @container media-root (width > 48rem) { font-size: 0.875rem; /* 14px at 100% font size */ } } diff --git a/packages/skins/src/minimal/css/components/slider.css b/packages/skins/src/minimal/css/components/slider.css index 876187d6..4fc03bc3 100644 --- a/packages/skins/src/minimal/css/components/slider.css +++ b/packages/skins/src/minimal/css/components/slider.css @@ -56,7 +56,7 @@ background-color: currentColor; border-radius: calc(infinity * 1px); box-shadow: - 0 0 0 1px var(--media-controls-current-shadow-color-subtle, oklch(0 0 0 / 0.1)), + 0 0 0 1px var(--media-current-shadow-color-subtle, oklch(0 0 0 / 0.1)), 0 1px 3px 0 oklch(0 0 0 / 0.15), 0 1px 2px -1px oklch(0 0 0 / 0.15); opacity: 0; diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index 4021bdc7..d9445841 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -14,7 +14,10 @@ @import "./components/slider.css"; @import "./components/popup.css"; @import "./components/captions.css"; +@import "./components/input-feedback.css"; @import "../../shared/css/video/icon-state.css"; +@import "../../shared/global/video/keyframes.css"; +@import "../../shared/global/video/properties.css"; /* ========================================================================== Root @@ -175,15 +178,13 @@ &:not([data-visible]) { pointer-events: none; opacity: 0; - translate: 0 100%; - @media (pointer: fine) { + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { filter: blur(8px); } - @media (prefers-reduced-motion: reduce) { - filter: blur(0); - translate: 0 0; + @media (prefers-reduced-motion: no-preference) { + translate: 0 100%; } } diff --git a/packages/skins/src/minimal/tailwind/audio.tailwind.ts b/packages/skins/src/minimal/tailwind/audio.tailwind.ts index 90f70e29..65a73797 100644 --- a/packages/skins/src/minimal/tailwind/audio.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/audio.tailwind.ts @@ -45,7 +45,7 @@ export const controls = cn( // Layout 'p-1.5 gap-2', 'rounded-(--media-border-radius,1rem)', - 'peer-data-open/error:[&_*]:invisible', + 'peer-data-open/error:**:invisible', // Appearance 'text-(--media-controls-text-color)', // Border diff --git a/packages/skins/src/minimal/tailwind/components/controls.ts b/packages/skins/src/minimal/tailwind/components/controls.ts index 7e96b293..070910c4 100644 --- a/packages/skins/src/minimal/tailwind/components/controls.ts +++ b/packages/skins/src/minimal/tailwind/components/controls.ts @@ -6,12 +6,9 @@ export const controls = cn( // Layout '@container/media-controls', 'flex items-center', - // Shadow color variables (derived from currentColor lightness) - '[--media-controls-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', - '[--media-controls-current-shadow-color-subtle:oklch(from_var(--media-controls-current-shadow-color)_l_c_h/calc(alpha*0.4))]', // Appearance (driven by CSS variables set on the root) 'bg-(--media-controls-background-color)', '[backdrop-filter:var(--media-controls-backdrop-filter)]', // Text shadow - 'text-shadow-2xs text-shadow-(color:--media-controls-current-shadow-color)' + 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)' ); diff --git a/packages/skins/src/minimal/tailwind/components/icon.ts b/packages/skins/src/minimal/tailwind/components/icon.ts index c040c401..17312da6 100644 --- a/packages/skins/src/minimal/tailwind/components/icon.ts +++ b/packages/skins/src/minimal/tailwind/components/icon.ts @@ -1,8 +1,8 @@ import { cn } from '@videojs/utils/style'; export const icon = cn( - 'block [grid-area:1/1] size-4.5', - 'drop-shadow-[0_1px_0_var(--media-controls-current-shadow-color,oklch(0_0_0/0.25))]', + 'block [grid-area:1/1] size-(--media-icon-size)', + 'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]', 'transition-discrete transition-[display,opacity] duration-150 ease-out' ); diff --git a/packages/skins/src/minimal/tailwind/components/input-feedback.ts b/packages/skins/src/minimal/tailwind/components/input-feedback.ts new file mode 100644 index 00000000..170d4beb --- /dev/null +++ b/packages/skins/src/minimal/tailwind/components/input-feedback.ts @@ -0,0 +1,154 @@ +import { cn } from '@videojs/utils/style'; + +/** + * NOTE: tailwind.css is required to support the `@property --media-progress-fill` registration and animation keyframes. You should import from either: +- "@videojs/html/tailwind.css" for HTML skins +- "@videojs/react/tailwind.css" for React skins + */ +export const inputFeedback = { + root: cn( + // Layout + 'absolute inset-x-0 top-0 bottom-14 pointer-events-none', + 'grid grid-cols-3 items-center justify-items-center overflow-hidden', + 'rounded-[inherit]', + // Shift to full extent in larger containers + '@2xl/media-root:bottom-0', + // Color + '[color:var(--media-color-primary,oklch(1_0_0))]' + ), + + island: { + // Minimal island is a top strip with a gradient backdrop (no surface treatment) + base: cn( + 'group/input-indicator', + 'absolute top-0 inset-x-0', + 'pt-3 pb-32', + 'flex justify-center', + 'text-inherit font-medium', + 'origin-top pointer-events-none', + // Transition + 'duration-100 ease-out', + 'data-starting-style:opacity-0', + 'data-ending-style:opacity-0', + 'data-starting-style:duration-400', + 'data-starting-style:ease-in', + 'data-ending-style:duration-400', + 'data-ending-style:ease-in', + '[background-image:linear-gradient(to_bottom,oklch(0_0_0/0.35),oklch(0_0_0/0.2)_3rem,oklch(0_0_0/0))]', + 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)', + // Pointer-dependent transition props + 'pointer-fine:will-change-[translate,filter,opacity]', + 'pointer-fine:transition-[translate,filter,opacity]', + 'pointer-coarse:will-change-[translate,opacity]', + 'pointer-coarse:transition-[translate,opacity]', + 'pointer-fine:motion-safe:data-starting-style:blur-sm', + 'pointer-fine:motion-safe:data-ending-style:blur-sm', + 'motion-safe:data-ending-style:-translate-y-full' + ), + content: cn( + 'flex justify-between items-center gap-2 px-2.5 py-1', + // Keep the label pinned to the end even when the icon is hidden during dismissal + '*:last:ml-auto', + // Reduced transparency / high contrast: solid content background + '[@media(prefers-reduced-transparency:reduce)]:bg-(--media-controls-background-color)', + '[@media(prefers-reduced-transparency:reduce)]:rounded-lg', + 'contrast-more:bg-(--media-controls-background-color) contrast-more:rounded-lg' + ), + // Volume island sizing + progress-fill on nested __progress element + volume: cn( + // Content is sized + '*:data-feedback-island-content:w-[min(80%,14rem)]' + ), + // Progress bar (nested inside content) — its own element in the minimal variant + volumeProgress: cn( + '[--media-progress-fill:var(--media-volume-fill)]', + 'w-full h-0.75 rounded-full', + '[background-image:linear-gradient(to_right,currentColor_0%,currentColor_var(--media-progress-fill),oklch(from_currentColor_l_c_h/0.2)_var(--media-progress-fill),oklch(from_currentColor_l_c_h/0.2)_100%)]', + 'shadow-[0_1px_0_var(--media-current-shadow-color-subtle)]' + ), + // Shown state — applied on the active item itself + shownVolume: cn( + 'data-open:duration-100', + // Boundary shake (keyframes must be registered and media-shake added to @theme — see note at top) + 'data-min:*:data-feedback-island-content:animate-media-shake', + 'data-max:*:data-feedback-island-content:animate-media-shake', + 'motion-reduce:data-min:*:data-feedback-island-content:animate-none', + 'motion-reduce:data-max:*:data-feedback-island-content:animate-none' + ), + shownStatus: cn('data-open:duration-100'), + // Icon inside island — hidden by default; specific icons opt in via shown* below. + icon: cn('hidden shrink-0', 'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]'), + shownVolumeHigh: 'group-data-[level=high]/input-indicator:block', + shownVolumeLow: 'group-data-[level=low]/input-indicator:block', + shownVolumeOff: 'group-data-[level=off]/input-indicator:block', + shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block', + shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block', + shownFullscreenEnter: cn( + 'group-data-[status=fullscreen]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in' + ), + shownFullscreenExit: cn( + 'group-data-[status=exit-fullscreen]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in' + ), + shownPipEnter: cn( + 'group-data-[status=pip]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in' + ), + shownPipExit: cn( + 'group-data-[status=exit-pip]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in' + ), + value: 'ml-auto', + }, + + bubble: { + base: cn( + 'group/input-indicator', + // Default placement — center column for status bubbles and undirected seeks + 'col-start-2 row-start-1', + 'flex flex-col items-center justify-center p-4', + 'transition-opacity duration-250 ease-out', + 'data-starting-style:opacity-0', + 'data-ending-style:opacity-0', + 'data-starting-style:duration-200', + 'data-starting-style:ease-in', + 'data-ending-style:duration-200', + 'data-ending-style:ease-in', + '@2xl/media-root:p-8', + 'not-data-direction:[transition-property:opacity,scale]', + 'not-data-direction:duration-600', + 'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]', + 'motion-reduce:not-data-direction:transition-opacity', + 'motion-reduce:not-data-direction:duration-100', + 'motion-reduce:not-data-direction:ease-out', + 'not-data-direction:data-starting-style:scale-80', + 'not-data-direction:data-ending-style:scale-80', + 'not-data-direction:data-starting-style:duration-200', + 'not-data-direction:data-starting-style:ease-in', + 'not-data-direction:data-ending-style:duration-200', + 'not-data-direction:data-ending-style:ease-in', + // Direction placement + 'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start', + 'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end' + ), + icon: 'hidden w-9 h-9', + shownSeek: cn( + 'group-data-direction/input-indicator:block', + 'group-data-[direction=backward]/input-indicator:-scale-x-100', + // Slide animation (keyframes registered in companion CSS) + 'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward', + 'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward', + 'motion-reduce:group-data-direction/input-indicator:animate-none' + ), + shownPause: cn( + 'group-data-[status=pause]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in' + ), + shownPlay: cn( + 'group-data-[status=play]/input-indicator:block', + 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in' + ), + time: 'tabular-nums', + }, +}; diff --git a/packages/skins/src/minimal/tailwind/components/reset.ts b/packages/skins/src/minimal/tailwind/components/reset.ts new file mode 100644 index 00000000..4bd4cd5f --- /dev/null +++ b/packages/skins/src/minimal/tailwind/components/reset.ts @@ -0,0 +1,9 @@ +import { cn } from '@videojs/utils/style'; + +export const reset = cn( + '**:box-border', + // Keep authored templates hidden even when component classes set display. + '[&_[hidden][hidden]]:hidden', + '[&_button]:font-[inherit]', + 'motion-safe:[interpolate-size:allow-keywords]' +); diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index 8b456a51..dc3d0aee 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -1,14 +1,21 @@ import { cn } from '@videojs/utils/style'; +import { reset } from './reset'; export const root = cn( + reset, // Layout & containment 'block relative isolate @container/media-root', // Appearance 'rounded-(--media-border-radius,0.75rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] leading-normal subpixel-antialiased', - '[&>*]:text-xs @3xl/media-root:[&>*]:text-sm', - // Resets - '**:box-border', - '[&_button]:font-[inherit]', - 'motion-safe:[interpolate-size:allow-keywords]' + '*:text-xs @3xl/media-root:*:text-sm', + // Focus ring + 'outline-2 outline-transparent -outline-offset-4', + 'transition-[outline-offset,outline-color] duration-100 ease-out', + 'focus-visible:outline-current focus-visible:outline-offset-2', + // Shadow color variables (derived from currentColor lightness) + '[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', + '[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]', + // Icon sizing + '[--media-icon-size:18px]' ); diff --git a/packages/skins/src/minimal/tailwind/components/slider.ts b/packages/skins/src/minimal/tailwind/components/slider.ts index 5b223168..0a50dcc6 100644 --- a/packages/skins/src/minimal/tailwind/components/slider.ts +++ b/packages/skins/src/minimal/tailwind/components/slider.ts @@ -40,7 +40,7 @@ export const slider = { base: cn( 'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2', 'bg-current rounded-full', - 'shadow-[0_0_0_1px_var(--media-controls-current-shadow-color-subtle,oklch(0_0_0/0.1)),0_1px_3px_0_oklch(0_0_0/0.15),0_1px_2px_-1px_oklch(0_0_0/0.15)]', + 'shadow-[0_0_0_1px_var(--media-current-shadow-color-subtle,oklch(0_0_0/0.1)),0_1px_3px_0_oklch(0_0_0/0.15),0_1px_2px_-1px_oklch(0_0_0/0.15)]', 'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none', 'outline-2 outline-transparent -outline-offset-2', 'focus-visible:outline-current focus-visible:outline-offset-2', diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index e2a7eb48..054d38e1 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -48,16 +48,16 @@ export const root = (isShadowDOM: boolean) => 'contrast-more:[--media-controls-background-color:oklch(0_0_0)]', '[@media(prefers-reduced-transparency:reduce)]:[--media-tooltip-background-color:oklch(0_0_0)]', 'contrast-more:[--media-tooltip-background-color:oklch(0_0_0)]', - '@2xl/media-root:[&>*]:[--media-popover-side-offset:0rem]', - '[@media(pointer:fine)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]', - '[@media(pointer:coarse)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]', + '@2xl/media-root:*:[--media-popover-side-offset:0rem]', + 'pointer-fine:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]', + 'pointer-coarse:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]', 'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:50ms]', // Caption track CSS variables (consumed by the native caption bridge in light DOM) '[--media-caption-track-y:-0.5rem]', '[--media-caption-track-delay:25ms]', '[--media-caption-track-duration:var(--media-controls-transition-duration)]', 'has-[[data-controls][data-visible]]:[--media-caption-track-y:-5rem]', - '@2xl/media-root:has-[[data-controls][data-visible]]:[&>*]:[--media-caption-track-y:-3rem]', + '@2xl/media-root:has-[[data-controls][data-visible]]:*:[--media-caption-track-y:-3rem]', // Native caption track container !isShadowDOM ? [ @@ -92,14 +92,14 @@ export const controls = cn( 'peer-data-open/error:hidden', 'ease-(--media-controls-transition-timing-function)', 'duration-(--media-controls-transition-duration)', - '[@media(pointer:fine)]:will-change-[translate,filter,opacity]', - '[@media(pointer:fine)]:transition-[translate,filter,opacity]', - '[@media(pointer:coarse)]:will-change-[translate,opacity]', - '[@media(pointer:coarse)]:transition-[translate,opacity]', + 'pointer-fine:will-change-[translate,filter,opacity]', + 'pointer-fine:transition-[translate,filter,opacity]', + 'pointer-coarse:will-change-[translate,opacity]', + 'pointer-coarse:transition-[translate,opacity]', // Hidden state 'not-data-visible:opacity-0 not-data-visible:pointer-events-none', 'motion-safe:not-data-visible:translate-y-full', - '[@media(pointer:fine)]:motion-safe:not-data-visible:blur-sm', + 'pointer-fine:motion-safe:not-data-visible:blur-sm', // Single-row layout (large) '@2xl/media-root:flex-nowrap @2xl/media-root:bottom-2 @2xl/media-root:inset-x-2' ); @@ -148,8 +148,8 @@ export const preview = { 'opacity-0 scale-80 blur-sm origin-bottom', 'transition-[scale,opacity,filter] duration-150', 'group-data-pointing/slider:opacity-100 group-data-pointing/slider:scale-100 group-data-pointing/slider:blur-none', - '[&:has([role=img][data-hidden])]:opacity-0 [&:has([role=img][data-hidden])]:scale-80 [&:has([role=img][data-hidden])]:blur-sm', - '[&:has([role=img][data-loading])]:max-h-24', + 'has-[[role=img][data-hidden]]:opacity-0 has-[[role=img][data-hidden]]:scale-80 has-[[role=img][data-hidden]]:blur-sm', + 'has-[[role=img][data-loading]]:max-h-24', basePreview.root ), thumbnailWrapper: cn( @@ -192,6 +192,7 @@ export { bufferingIndicator } from './components/buffering'; export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; +export { inputFeedback } from './components/input-feedback'; export { overlay } from './components/overlay'; export { playbackRate } from './components/playback-rate'; export { poster } from './components/poster'; diff --git a/packages/skins/src/shared/global/video/keyframes.css b/packages/skins/src/shared/global/video/keyframes.css new file mode 100644 index 00000000..f7b78787 --- /dev/null +++ b/packages/skins/src/shared/global/video/keyframes.css @@ -0,0 +1,43 @@ +/* -------------------------------------------------------------------------- */ +/* Global @keyframes for all video skins (CSS & Tailwind) */ +/* -------------------------------------------------------------------------- */ + +@keyframes media-shake { + 0%, + 100% { + translate: 0 0; + } + 20% { + translate: -6px 0; + } + 40% { + translate: 4px 0; + } + 60% { + translate: -2px 0; + } + 80% { + translate: 1px 0; + } +} + +@keyframes media-slide-in-forward { + from { + translate: -60% 0; + opacity: 0; + } +} + +@keyframes media-slide-in-backward { + from { + translate: 60% 0; + opacity: 0; + } +} + +@keyframes media-pop-in { + from { + scale: 0.8; + opacity: 0; + } +} diff --git a/packages/skins/src/shared/global/video/properties.css b/packages/skins/src/shared/global/video/properties.css new file mode 100644 index 00000000..5a3f2a64 --- /dev/null +++ b/packages/skins/src/shared/global/video/properties.css @@ -0,0 +1,9 @@ +/* -------------------------------------------------------------------------- */ +/* Global @properties for all video skins (CSS & Tailwind) */ +/* -------------------------------------------------------------------------- */ + +@property --media-progress-fill { + syntax: ""; + inherits: true; + initial-value: 0%; +} diff --git a/packages/skins/src/shared/tailwind.css b/packages/skins/src/shared/tailwind.css new file mode 100644 index 00000000..abe3cc02 --- /dev/null +++ b/packages/skins/src/shared/tailwind.css @@ -0,0 +1,13 @@ +/* -------------------------------------------------------------------------- */ +/* Supporting @keyframes, @properties and @theme for Tailwind CSS */ +/* -------------------------------------------------------------------------- */ + +@import "./global/video/keyframes.css"; +@import "./global/video/properties.css"; + +@theme { + --animate-media-shake: media-shake 300ms ease-in-out; + --animate-media-slide-in-forward: media-slide-in-forward 300ms ease-in-out; + --animate-media-slide-in-backward: media-slide-in-backward 300ms ease-in-out; + --animate-media-pop-in: media-pop-in 250ms ease-out; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fb512a0..65da9efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,9 +323,6 @@ importers: '@svgr/plugin-jsx': specifier: ^8.1.0 version: 8.1.0(@svgr/core@8.1.0(typescript@6.0.2)) - '@svgr/plugin-svgo': - specifier: ^8.1.0 - version: 8.1.0(@svgr/core@8.1.0(typescript@6.0.2))(typescript@6.0.2) '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -3137,12 +3134,6 @@ packages: peerDependencies: '@svgr/core': '*' - '@svgr/plugin-svgo@8.1.0': - resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} - engines: {node: '>=14'} - peerDependencies: - '@svgr/core': '*' - '@svta/cml-608@1.0.1': resolution: {integrity: sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg==} engines: {node: '>=20'} @@ -4221,10 +4212,6 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -4353,10 +4340,6 @@ packages: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -6024,9 +6007,6 @@ packages: mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -7317,11 +7297,6 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - svgo@3.3.3: - resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} - engines: {node: '>=14.0.0'} - hasBin: true - svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -10926,15 +10901,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@6.0.2))(typescript@6.0.2)': - dependencies: - '@svgr/core': 8.1.0(typescript@6.0.2) - cosmiconfig: 8.3.6(typescript@6.0.2) - deepmerge: 4.3.1 - svgo: 3.3.3 - transitivePeerDependencies: - - typescript - '@svta/cml-608@1.0.1': {} '@svta/cml-cmcd@1.0.1(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)': @@ -12254,8 +12220,6 @@ snapshots: commander@2.20.3: {} - commander@7.2.0: {} - common-ancestor-path@2.0.0: {} common-path-prefix@3.0.0: {} @@ -12389,11 +12353,6 @@ snapshots: mdn-data: 2.0.28 source-map-js: 1.2.1 - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -14389,8 +14348,6 @@ snapshots: mdn-data@2.0.28: {} - mdn-data@2.0.30: {} - mdn-data@2.27.1: {} meow@12.1.1: {} @@ -15967,16 +15924,6 @@ snapshots: svg-parser@2.0.4: {} - svgo@3.3.3: - dependencies: - commander: 7.2.0 - css-select: 5.2.2 - css-tree: 2.3.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.5.0 - svgo@4.0.1: dependencies: commander: 11.1.0