diff --git a/apps/e2e/fixtures/selectors.ts b/apps/e2e/fixtures/selectors.ts index 8529c2ab..27703029 100644 --- a/apps/e2e/fixtures/selectors.ts +++ b/apps/e2e/fixtures/selectors.ts @@ -15,6 +15,14 @@ function withinControls(selector: string): string { return `media-controls ${selector}, .media-controls ${selector}`; } +/** Scope a descendant selector to each comma-separated panel matcher. */ +function withinMenuPanel(panelSelector: string, descendant: string): string { + return panelSelector + .split(',') + .map((panel) => `${panel.trim()} ${descendant}`) + .join(', '); +} + export const SELECTORS = { // Player containers // HTML: , React: wrapper div around VideoSkin @@ -35,19 +43,41 @@ export const SELECTORS = { pipButton: 'media-pip-button, .media-button--pip', captionsButton: 'media-captions-button, .media-button--captions', playbackRateButton: [ - // Legacy cycle button / CSS skins (explicit class) withinControls('media-playback-rate-button'), withinControls('.media-button--playback-rate'), - // HTML menu trigger — exclude nested settings-submenu rows (.media-menu__item) - withinControls('media-playback-rate-menu-trigger:not(.media-menu__item)'), - // Tailwind skins (utility classes only) + React menu triggers withinControls('button[aria-haspopup="menu"][aria-label^="Playback rate"]:not(.media-menu__item)'), ].join(', '), - /** - * Open playback rate surface: trigger is also `data-rate`, so require `role="menu"` too - * (covers HTML `` and React/Tailwind without `media-menu--playback-rate`). - */ - playbackRateMenuPanel: '[data-rate][role="menu"]', + playbackRateMenuPanel: '#playback-rate-menu[role="menu"], [role="menu"]#playback-rate-menu', + playbackRateMenuRadioItems: withinMenuPanel( + '#playback-rate-menu[role="menu"], [role="menu"]#playback-rate-menu', + '[role="menuitemradio"]' + ), + /** Standalone playback rate menu items (HTML id + React open menu in controls). */ + openPlaybackRateMenuRadioItems: [ + withinMenuPanel('#playback-rate-menu[role="menu"], [role="menu"]#playback-rate-menu', '[role="menuitemradio"]'), + withinControls('[role="menu"] [role="menuitemradio"]'), + ].join(', '), + /** Currently visible settings submenu panel (HTML + React). */ + activeMenuPanel: '[role="menu"][data-menu-view-state="active"]', + activeMenuRadioItems: '[role="menu"][data-menu-view-state="active"] [role="menuitemradio"]', + settingsButton: [ + withinControls('.media-button--settings'), + withinControls('button[commandfor="settings-menu"]'), + withinControls('button[aria-label="Settings"]'), + ].join(', '), + settingsCaptionsItem: [ + 'media-menu-item[commandfor="settings-captions-menu"]', + '[role="menuitem"]:has-text("Captions")', + ].join(', '), + settingsSpeedItem: ['media-menu-item[commandfor="settings-speed-menu"]', '[role="menuitem"]:has-text("Speed")'].join( + ', ' + ), + settingsSpeedMenuPanel: '#settings-speed-menu[role="menu"], [role="menu"]#settings-speed-menu', + settingsCaptionsMenuPanel: '#settings-captions-menu[role="menu"], [role="menu"]#settings-captions-menu', + settingsCaptionsMenuRadioItems: withinMenuPanel( + '#settings-captions-menu[role="menu"], [role="menu"]#settings-captions-menu', + '[role="menuitemradio"]' + ), // Sliders // HTML: , React: horizontal .media-slider inside .media-time-controls diff --git a/apps/e2e/page-objects/player.ts b/apps/e2e/page-objects/player.ts index e32a7d07..d48236c1 100644 --- a/apps/e2e/page-objects/player.ts +++ b/apps/e2e/page-objects/player.ts @@ -62,6 +62,18 @@ export class PlayerPage { return this.page.locator(SELECTORS.playbackRateButton).first(); } + get settingsButton(): Locator { + return this.page.locator(SELECTORS.settingsButton).first(); + } + + get settingsCaptionsItem(): Locator { + return this.page.locator(SELECTORS.settingsCaptionsItem).first(); + } + + get settingsSpeedItem(): Locator { + return this.page.locator(SELECTORS.settingsSpeedItem).first(); + } + get poster(): Locator { return this.page.locator(SELECTORS.poster).first(); } @@ -149,6 +161,30 @@ export class PlayerPage { await expect(this.playButton).toHaveAttribute(DATA_ATTRS.paused, '', { timeout: 5_000 }); } + async openPlaybackRateSettings(): Promise { + await this.showControls(); + await this.settingsButton.click(); + await expect(this.settingsSpeedItem).toBeVisible(); + await this.settingsSpeedItem.click(); + await expect(this.page.locator(`${SELECTORS.activeMenuPanel} [role="menuitemradio"]`).first()).toBeVisible(); + } + + async openCaptionsSettings(): Promise { + await this.showControls(); + await this.settingsButton.click(); + await expect(this.settingsCaptionsItem).toBeVisible(); + await this.settingsCaptionsItem.click(); + await expect(this.page.locator(SELECTORS.activeMenuRadioItems).first()).toBeVisible(); + } + + async getPlaybackRate(): Promise { + return this.page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + return actual?.playbackRate ?? 1; + }, SELECTORS.media); + } + /** Click at a percentage position on the time slider. */ async seekTo(percent: number): Promise { // Wait for readyState >= 1 (HAVE_METADATA) and a non-zero duration. @@ -190,12 +226,32 @@ export class PlayerPage { * Skins expose rate via a menu (not cycle-on-trigger). */ async selectAlternativePlaybackRate(): Promise { - await this.playbackRateButton.click(); + const initialRate = await this.getPlaybackRate(); + const usesSettingsMenu = (await this.settingsButton.count()) > 0; + + if (usesSettingsMenu) { + await this.openPlaybackRateSettings(); + } else { + await this.showControls(); + await this.playbackRateButton.click(); + } + + const uncheckedPlaybackRateOptions = SELECTORS.openPlaybackRateMenuRadioItems + .split(', ') + .map((selector) => `${selector.trim()}[aria-checked="false"]`) + .join(', '); const option = this.page - .locator(`${SELECTORS.playbackRateMenuPanel} [role="menuitemradio"][aria-checked="false"]`) + .locator( + usesSettingsMenu + ? `${SELECTORS.activeMenuPanel} [role="menuitemradio"][aria-checked="false"]` + : uncheckedPlaybackRateOptions + ) .first(); await expect(option).toBeVisible({ timeout: 5_000 }); - await option.click(); + // Menu popovers can intercept pointer events on nested radio items. + await option.dispatchEvent('click'); + + await expect.poll(async () => this.getPlaybackRate()).not.toBe(initialRate); } /** Hover over the player area to trigger user-active state and show controls. */ diff --git a/apps/e2e/tests/captions.spec.ts b/apps/e2e/tests/captions.spec.ts index fe548158..a50d8b98 100644 --- a/apps/e2e/tests/captions.spec.ts +++ b/apps/e2e/tests/captions.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { SELECTORS } from '../fixtures/selectors'; import { PlayerPage } from '../page-objects/player'; test.describe('Captions', () => { @@ -11,18 +11,13 @@ test.describe('Captions', () => { await player.waitForMediaReady(); }); - test('captions button shows unavailable without subtitle tracks', async ({ page }) => { - const captionsBtn = page.locator(SELECTORS.captionsButton).first(); - await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'unavailable'); + test('captions settings hidden without subtitle tracks', async () => { + await player.showControls(); + await player.settingsButton.click(); + await expect(player.settingsCaptionsItem).toBeHidden(); }); - test('captions button becomes available when subtitle track is added', async ({ page }) => { - const captionsBtn = page.locator(SELECTORS.captionsButton).first(); - - // Initially unavailable - await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'unavailable'); - - // Add a subtitle track + test('captions settings lists tracks when subtitle track is added', async ({ page }) => { await page.evaluate(() => { const video = document.querySelector('video') as HTMLVideoElement; if (!video) return; @@ -35,9 +30,10 @@ test.describe('Captions', () => { video.appendChild(track); }); - // Button should switch to available - await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'available', { - timeout: 5_000, - }); + await player.showControls(); + await player.openCaptionsSettings(); + + const options = page.locator(SELECTORS.activeMenuRadioItems); + await expect(options).toHaveCount(2, { timeout: 5_000 }); }); }); diff --git a/apps/e2e/tests/video-controls.spec.ts b/apps/e2e/tests/video-controls.spec.ts index c072935c..d6efc86b 100644 --- a/apps/e2e/tests/video-controls.spec.ts +++ b/apps/e2e/tests/video-controls.spec.ts @@ -4,8 +4,7 @@ import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; import { PlayerPage } from '../page-objects/player'; for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly PageEntry[]) { - /** Packaged skins use an inline rate control; CDN + site-ejected previews only expose nested settings menus. */ - const skipsInlinePlaybackRateMenuTest = path.includes('/cdn-video') || path.includes('/ejected'); + const usesSettingsMenu = !path.includes('/cdn-video') && !path.includes('/ejected'); test.describe(`Video Controls — ${name}`, () => { test.skip(({ browserName }) => { return skipBrowsers?.includes(browserName as 'chromium' | 'webkit' | 'firefox') ?? false; @@ -28,7 +27,11 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel); await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability); await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability); - await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.availability); + if (usesSettingsMenu) { + await expect(player.settingsButton).toBeAttached(); + } else { + await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.availability); + } await expect(player.duration).not.toHaveText(''); await player.showControls(); await expect(player.controls).toBeAttached(); @@ -93,13 +96,12 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa // --- Playback Rate --- - (skipsInlinePlaybackRateMenuTest ? test.skip : test)('playback rate menu changes selected rate', async () => { - const rateBtn = player.playbackRateButton; - const initialRate = await rateBtn.getAttribute(DATA_ATTRS.rate); + (usesSettingsMenu ? test : test.skip)('playback rate menu changes selected rate', async () => { + const initialRate = await player.getPlaybackRate(); await player.selectAlternativePlaybackRate(); - await expect.poll(async () => rateBtn.getAttribute(DATA_ATTRS.rate)).not.toBe(initialRate); + await expect.poll(async () => player.getPlaybackRate()).not.toBe(initialRate); }); // --- Poster --- diff --git a/apps/e2e/tests/visual/video-skin.spec.ts b/apps/e2e/tests/visual/video-skin.spec.ts index 5f11b67c..5c88f083 100644 --- a/apps/e2e/tests/visual/video-skin.spec.ts +++ b/apps/e2e/tests/visual/video-skin.spec.ts @@ -64,15 +64,11 @@ test.describe('Visual — Captions', () => { test('captions enabled', async ({ page, browserName }) => { // WebKit doesn't render data:text/vtt subtitle tracks in headless mode test.skip(browserName === 'webkit', 'WebKit headless does not render data:text/vtt captions'); - // Captions button should be available (subtitle track is in the HTML) - const captionsBtn = page.locator(SELECTORS.captionsButton).first(); - await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.availability, 'available', { - timeout: 5_000, - }); - // Enable captions - await captionsBtn.click(); - await expect(captionsBtn).toHaveAttribute(DATA_ATTRS.active, ''); + await player.showControls(); + await player.openCaptionsSettings(); + await expect(page.locator(SELECTORS.activeMenuRadioItems)).toHaveCount(2); + await page.locator(SELECTORS.activeMenuRadioItems).nth(1).dispatchEvent('click'); // Play briefly so the caption cue at 0:00 activates, then pause await player.play(); diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index a8bb3565..e0ced1f4 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -8,6 +8,8 @@ export * from './ui/buffering-indicator/buffering-indicator-core'; export * from './ui/buffering-indicator/buffering-indicator-data-attrs'; export * from './ui/captions-button/captions-button-core'; export * from './ui/captions-button/captions-button-data-attrs'; +export * from './ui/captions-radio-group/captions-radio-group-core'; +export * from './ui/captions-radio-group/captions-radio-group-data-attrs'; export * from './ui/cast-button/cast-button-core'; export * from './ui/cast-button/cast-button-data-attrs'; export * from './ui/controls/controls-core'; @@ -40,8 +42,8 @@ export * from './ui/play-button/play-button-core'; export * from './ui/play-button/play-button-data-attrs'; export * from './ui/playback-rate-button/playback-rate-button-core'; export * from './ui/playback-rate-button/playback-rate-button-data-attrs'; -export * from './ui/playback-rate-menu/playback-rate-menu-core'; -export * from './ui/playback-rate-menu/playback-rate-menu-data-attrs'; +export * from './ui/playback-rate-radio-group/playback-rate-radio-group-core'; +export * from './ui/playback-rate-radio-group/playback-rate-radio-group-data-attrs'; export * from './ui/popover/popover-core'; export * from './ui/popover/popover-css-vars'; export * from './ui/popover/popover-data-attrs'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index d4b9151a..cd383a2b 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -259,6 +259,7 @@ export type TextTrackMode = 'showing' | 'disabled' | 'hidden'; * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack */ export interface MediaTextTrack { + id?: string; kind: Kind; label: string; language: string; @@ -278,6 +279,8 @@ export interface MediaTextTrackState { subtitlesShowing: boolean; /** Toggle captions/subtitles visibility. Returns the new enabled value. */ toggleSubtitles(forceShow?: boolean): boolean; + /** Select a captions/subtitles track by menu value, or disable with `"off"`. */ + selectSubtitlesTrack(value: string): void; } export interface MediaError { diff --git a/packages/core/src/core/ui/captions-button/captions-button-core.ts b/packages/core/src/core/ui/captions-button/captions-button-core.ts index 21e33ee1..7bb009b3 100644 --- a/packages/core/src/core/ui/captions-button/captions-button-core.ts +++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts @@ -12,6 +12,8 @@ export interface CaptionsButtonProps { label?: string | ((state: CaptionsButtonState) => string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; + /** When true with multiple tracks, pointer activation opens a menu instead of toggling. React sets this automatically inside `Menu.Trigger`. */ + menuTrigger?: boolean | undefined; } export interface CaptionsButtonState extends Pick, ButtonState { @@ -22,6 +24,7 @@ export class CaptionsButtonCore { static readonly defaultProps: NonNullableObject = { label: '', disabled: false, + menuTrigger: false, }; readonly state = createState({ @@ -79,10 +82,15 @@ export class CaptionsButtonCore { toggle(media: MediaTextTrackState): void { if (this.#props.disabled) return; + if (this.#props.menuTrigger && getCaptionTrackCount(media) > 1) return; media.toggleSubtitles(); } } +function getCaptionTrackCount(media: MediaTextTrackState): number { + return media.textTrackList.filter(isCaptionOrSubtitleTrack).length; +} + export namespace CaptionsButtonCore { export type Props = CaptionsButtonProps; export type State = CaptionsButtonState; diff --git a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts index 146b1bcc..14a45189 100644 --- a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts +++ b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts @@ -12,6 +12,7 @@ function createMediaState(overrides: Partial = {}): MediaTe textTrackList: [], subtitlesShowing: false, toggleSubtitles: vi.fn(() => true), + selectSubtitlesTrack: vi.fn(), ...overrides, }; } @@ -56,7 +57,9 @@ describe('CaptionsButtonCore', () => { it('returns unavailable when no subtitles', () => { const core = new CaptionsButtonCore(); core.setMedia( - createMediaState({ textTrackList: [{ kind: 'metadata', label: 'thumbnails', language: '', mode: 'hidden' }] }) + createMediaState({ + textTrackList: [{ kind: 'metadata', label: 'thumbnails', language: '', mode: 'hidden' }], + }) ); expect(core.getState().availability).toBe('unavailable'); @@ -115,5 +118,26 @@ describe('CaptionsButtonCore', () => { core.toggle(media); expect(media.toggleSubtitles).not.toHaveBeenCalled(); }); + + it('does not toggle when acting as a menu trigger with multiple tracks', () => { + const core = new CaptionsButtonCore({ menuTrigger: true }); + const media = createMediaState({ + textTrackList: [ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + }); + core.toggle(media); + expect(media.toggleSubtitles).not.toHaveBeenCalled(); + }); + + it('still toggles as a menu trigger with a single track', () => { + const core = new CaptionsButtonCore({ menuTrigger: true }); + const media = createMediaState({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }], + }); + core.toggle(media); + expect(media.toggleSubtitles).toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts new file mode 100644 index 00000000..2de30b8a --- /dev/null +++ b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts @@ -0,0 +1,150 @@ +import { createState } from '@videojs/store'; +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaTextTrack, MediaTextTrackState } from '../../media/state'; +import type { ButtonState } from '../types'; + +export interface CaptionsRadioGroupProps { + /** Custom label for the menu trigger. */ + label?: string | ((state: CaptionsRadioGroupState) => string) | undefined; + /** Custom formatter for visible track labels. */ + formatTrack?: ((track: MediaTextTrack) => string) | undefined; + /** Whether track selection is disabled. */ + disabled?: boolean | undefined; +} + +export interface CaptionsRadioGroupTrack { + value: string; + label: string; +} + +export interface CaptionsRadioGroupState extends Pick, ButtonState { + tracks: readonly CaptionsRadioGroupTrack[]; + value: string; + disabled: boolean; + availability: 'available' | 'unavailable'; +} + +export const CAPTIONS_OFF_VALUE = 'off'; + +function formatTrackLabel(track: MediaTextTrack): string { + if (track.label) return track.label; + if (track.language) return track.language; + return track.kind === 'captions' ? 'Captions' : 'Subtitles'; +} + +function sortCaptionTracks(a: MediaTextTrack, b: MediaTextTrack): number { + return a.kind > b.kind ? 1 : a.kind < b.kind ? -1 : 0; +} + +function getCaptionTracks(textTrackList: readonly MediaTextTrack[]): MediaTextTrack[] { + return textTrackList.filter(isCaptionOrSubtitleTrack).sort(sortCaptionTracks); +} + +export class CaptionsRadioGroupCore { + static readonly defaultProps: NonNullableObject = { + label: '', + formatTrack: formatTrackLabel, + disabled: false, + }; + + readonly state = createState({ + tracks: [], + value: CAPTIONS_OFF_VALUE, + subtitlesShowing: false, + disabled: false, + availability: 'unavailable', + label: '', + }); + + #props = { ...CaptionsRadioGroupCore.defaultProps }; + #media: MediaTextTrackState | null = null; + + constructor(props?: CaptionsRadioGroupProps) { + if (props) this.setProps(props); + } + + setProps(props: CaptionsRadioGroupProps): void { + this.#props = defaults(props, CaptionsRadioGroupCore.defaultProps); + } + + getLabel(state: CaptionsRadioGroupState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + return state.subtitlesShowing ? 'Disable captions' : 'Enable captions'; + } + + getTrackLabel(track: MediaTextTrack): string { + return this.#props.formatTrack(track); + } + + getAttrs(state: CaptionsRadioGroupState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': state.disabled ? 'true' : undefined, + }; + } + + setMedia(media: MediaTextTrackState): void { + this.#media = media; + } + + getState(): CaptionsRadioGroupState { + const media = this.#media!; + const captionTracks = getCaptionTracks(media.textTrackList); + const showingIndex = captionTracks.findIndex((track) => track.mode === 'showing'); + const tracks = captionTracks.map((track, index) => ({ + value: track.id || String(index), + label: this.getTrackLabel(track), + })); + + const availability: CaptionsRadioGroupState['availability'] = + captionTracks.length > 0 ? 'available' : 'unavailable'; + + this.state.patch({ + tracks, + value: showingIndex === -1 ? CAPTIONS_OFF_VALUE : captionTracks[showingIndex]!.id || String(showingIndex), + subtitlesShowing: media.subtitlesShowing, + disabled: this.#props.disabled || captionTracks.length === 0, + availability, + }); + this.state.patch({ label: this.getLabel(this.state.current) }); + + return this.state.current; + } + + select(media: MediaTextTrackState, value: string): void { + if (this.#props.disabled) return; + + const captionTracks = getCaptionTracks(media.textTrackList); + if (!captionTracks.length) return; + + if (value === CAPTIONS_OFF_VALUE) { + media.selectSubtitlesTrack(CAPTIONS_OFF_VALUE); + return; + } + + if (!captionTracks.some((track, index) => (track.id || String(index)) === value)) return; + + media.selectSubtitlesTrack(value); + } + + selectValue(media: MediaTextTrackState, value: string): void { + this.select(media, value); + } +} + +export namespace CaptionsRadioGroupCore { + export type Props = CaptionsRadioGroupProps; + export type State = CaptionsRadioGroupState; +} diff --git a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-data-attrs.ts b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-data-attrs.ts new file mode 100644 index 00000000..f33f1775 --- /dev/null +++ b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-data-attrs.ts @@ -0,0 +1,11 @@ +import type { StateAttrMap } from '../types'; +import type { CaptionsRadioGroupState } from './captions-radio-group-core'; + +export const CaptionsRadioGroupDataAttrs = { + /** Present when captions are enabled. */ + subtitlesShowing: 'data-active', + /** Present when track selection is disabled. */ + disabled: 'data-disabled', + /** Indicates captions availability (`available` or `unavailable`). */ + availability: 'data-availability', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/captions-radio-group/tests/captions-radio-group-core.test.ts b/packages/core/src/core/ui/captions-radio-group/tests/captions-radio-group-core.test.ts new file mode 100644 index 00000000..d7b35a53 --- /dev/null +++ b/packages/core/src/core/ui/captions-radio-group/tests/captions-radio-group-core.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaTextTrackState } from '../../../media/state'; +import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore, type CaptionsRadioGroupState } from '../captions-radio-group-core'; + +function createMediaState(overrides: Partial = {}): MediaTextTrackState { + return { + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + textTrackList: [], + subtitlesShowing: false, + toggleSubtitles: vi.fn(() => true), + selectSubtitlesTrack: vi.fn(), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): CaptionsRadioGroupState { + return { + tracks: [], + value: CAPTIONS_OFF_VALUE, + subtitlesShowing: false, + disabled: false, + availability: 'unavailable', + label: '', + ...overrides, + }; +} + +describe('CaptionsRadioGroupCore', () => { + describe('getState', () => { + it('projects caption tracks and the active value', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + subtitlesShowing: true, + textTrackList: [ + { id: 'subtitles-en', kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { id: 'captions-en', kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' }, + { id: 'subtitles-es', kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + }); + core.setMedia(media); + const state = core.getState(); + + expect(state.tracks).toEqual([ + { value: 'captions-en', label: 'CC' }, + { value: 'subtitles-en', label: 'English' }, + { value: 'subtitles-es', label: 'Spanish' }, + ]); + expect(state.value).toBe('subtitles-en'); + expect(state.subtitlesShowing).toBe(true); + }); + + it('marks state disabled when no caption tracks are available', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [{ kind: 'metadata', label: 'thumbnails', language: '', mode: 'hidden' }], + }); + core.setMedia(media); + + expect(core.getState().disabled).toBe(true); + }); + + it('marks availability unavailable when no caption tracks are available', () => { + const core = new CaptionsRadioGroupCore(); + core.setMedia(createMediaState()); + + expect(core.getState().availability).toBe('unavailable'); + }); + + it('marks availability available when caption tracks exist', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }], + }); + core.setMedia(media); + + expect(core.getState().availability).toBe('available'); + }); + + it('uses off when no track is showing', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [ + { kind: 'captions', label: 'English', language: 'en', mode: 'disabled' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + }); + core.setMedia(media); + + expect(core.getState().value).toBe(CAPTIONS_OFF_VALUE); + }); + }); + + describe('getLabel', () => { + it('returns default labels based on showing state', () => { + const core = new CaptionsRadioGroupCore(); + expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions'); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions'); + }); + + it('returns custom string label', () => { + const core = new CaptionsRadioGroupCore({ label: 'Captions' }); + expect(core.getLabel(createState())).toBe('Captions'); + }); + + it('returns custom function label', () => { + const core = new CaptionsRadioGroupCore({ + label: (state) => (state.subtitlesShowing ? 'Hide subtitles' : 'Show subtitles'), + }); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Hide subtitles'); + }); + }); + + describe('getTrackLabel', () => { + it('formats track labels by default', () => { + const core = new CaptionsRadioGroupCore(); + expect( + core.getTrackLabel({ + kind: 'subtitles', + label: 'English', + language: 'en', + mode: 'disabled', + }) + ).toBe('English'); + expect(core.getTrackLabel({ kind: 'subtitles', label: '', language: 'es', mode: 'disabled' })).toBe('es'); + expect(core.getTrackLabel({ kind: 'captions', label: '', language: '', mode: 'disabled' })).toBe('Captions'); + }); + + it('uses a custom formatter', () => { + const core = new CaptionsRadioGroupCore({ + formatTrack: (track) => `${track.language.toUpperCase()} subtitles`, + }); + + expect( + core.getTrackLabel({ + kind: 'subtitles', + label: 'English', + language: 'en', + mode: 'disabled', + }) + ).toBe('EN subtitles'); + }); + }); + + describe('select', () => { + it('selects a track from the available list', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [ + { id: 'subtitles-en', kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }, + { id: 'subtitles-es', kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + }); + core.select(media, 'subtitles-es'); + expect(media.selectSubtitlesTrack).toHaveBeenCalledWith('subtitles-es'); + }); + + it('turns captions off', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }], + }); + core.select(media, CAPTIONS_OFF_VALUE); + expect(media.selectSubtitlesTrack).toHaveBeenCalledWith(CAPTIONS_OFF_VALUE); + }); + + it('does nothing when disabled', () => { + const core = new CaptionsRadioGroupCore({ disabled: true }); + const media = createMediaState({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }], + }); + core.select(media, 'subtitles-en'); + expect(media.selectSubtitlesTrack).not.toHaveBeenCalled(); + }); + + it('does nothing for unavailable tracks', () => { + const core = new CaptionsRadioGroupCore(); + const media = createMediaState({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }], + }); + core.select(media, 'subtitles-es'); + expect(media.selectSubtitlesTrack).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts index 15d32226..71cd4c35 100644 --- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts @@ -11,6 +11,8 @@ export interface PlaybackRateButtonProps { label?: string | ((state: PlaybackRateButtonState) => string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; + /** When true, pointer activation opens a menu instead of cycling. React sets this automatically inside `Menu.Trigger`. */ + menuTrigger?: boolean | undefined; } export interface PlaybackRateButtonState extends ButtonState { @@ -21,6 +23,7 @@ export class PlaybackRateButtonCore { static readonly defaultProps: NonNullableObject = { label: '', disabled: false, + menuTrigger: false, }; readonly state = createState({ @@ -73,6 +76,7 @@ export class PlaybackRateButtonCore { cycle(media: MediaPlaybackRateState): void { if (this.#props.disabled) return; + if (this.#props.menuTrigger) return; const { playbackRates, playbackRate } = media; if (playbackRates.length === 0) return; diff --git a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts index ded4620e..cc281116 100644 --- a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts +++ b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts @@ -134,6 +134,13 @@ describe('PlaybackRateButtonCore', () => { expect(media.setPlaybackRate).toHaveBeenCalledWith(0.5); }); + it('does nothing when menuTrigger is set', () => { + const core = new PlaybackRateButtonCore({ menuTrigger: true }); + const media = createMediaState({ playbackRate: 1 }); + core.cycle(media); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + it('does nothing when disabled', () => { const core = new PlaybackRateButtonCore({ disabled: true }); const media = createMediaState(); diff --git a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts b/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts deleted file mode 100644 index 0f106066..00000000 --- a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { StateAttrMap } from '../types'; -import type { PlaybackRateMenuState } from './playback-rate-menu-core'; - -export const PlaybackRateMenuDataAttrs = { - /** Current playback rate. */ - rate: 'data-rate', - /** Present when playback rate selection is disabled. */ - disabled: 'data-disabled', -} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts similarity index 65% rename from packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts rename to packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts index 5f9f9624..e582c99a 100644 --- a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts @@ -6,51 +6,53 @@ import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPlaybackRateState } from '../../media/state'; import type { ButtonState } from '../types'; -export interface PlaybackRateMenuProps { - /** Custom label for the menu trigger. */ - label?: string | ((state: PlaybackRateMenuState) => string) | undefined; +export interface PlaybackRateRadioGroupProps { + /** Custom label for the options group. */ + label?: string | ((state: PlaybackRateRadioGroupState) => string) | undefined; /** Custom formatter for visible playback rate labels. */ formatRate?: ((rate: number) => string) | undefined; /** Whether playback rate selection is disabled. */ disabled?: boolean | undefined; } -export interface PlaybackRateMenuState extends ButtonState { +export interface PlaybackRateRadioGroupState extends ButtonState { rate: number; rates: readonly number[]; disabled: boolean; + availability: 'available' | 'unavailable'; } function formatPlaybackRate(rate: number): string { return `${rate}×`; } -export class PlaybackRateMenuCore { - static readonly defaultProps: NonNullableObject = { +export class PlaybackRateRadioGroupCore { + static readonly defaultProps: NonNullableObject = { label: '', formatRate: formatPlaybackRate, disabled: false, }; - readonly state = createState({ + readonly state = createState({ rate: 1, rates: [], disabled: false, + availability: 'unavailable', label: '', }); - #props = { ...PlaybackRateMenuCore.defaultProps }; + #props = { ...PlaybackRateRadioGroupCore.defaultProps }; #media: MediaPlaybackRateState | null = null; - constructor(props?: PlaybackRateMenuProps) { + constructor(props?: PlaybackRateRadioGroupProps) { if (props) this.setProps(props); } - setProps(props: PlaybackRateMenuProps): void { - this.#props = defaults(props, PlaybackRateMenuCore.defaultProps); + setProps(props: PlaybackRateRadioGroupProps): void { + this.#props = defaults(props, PlaybackRateRadioGroupCore.defaultProps); } - getLabel(state: PlaybackRateMenuState): string { + getLabel(state: PlaybackRateRadioGroupState): string { const { label } = this.#props; if (isFunction(label)) { @@ -71,7 +73,7 @@ export class PlaybackRateMenuCore { return String(rate); } - getAttrs(state: PlaybackRateMenuState) { + getAttrs(state: PlaybackRateRadioGroupState) { return { 'aria-label': this.getLabel(state), 'aria-disabled': state.disabled ? 'true' : undefined, @@ -82,13 +84,17 @@ export class PlaybackRateMenuCore { this.#media = media; } - getState(): PlaybackRateMenuState { + getState(): PlaybackRateRadioGroupState { const media = this.#media!; + const availability: PlaybackRateRadioGroupState['availability'] = + media.playbackRates.length > 0 ? 'available' : 'unavailable'; + this.state.patch({ rate: media.playbackRate, rates: media.playbackRates, disabled: this.#props.disabled || media.playbackRates.length === 0, + availability, }); this.state.patch({ label: this.getLabel(this.state.current) }); @@ -110,7 +116,7 @@ export class PlaybackRateMenuCore { } } -export namespace PlaybackRateMenuCore { - export type Props = PlaybackRateMenuProps; - export type State = PlaybackRateMenuState; +export namespace PlaybackRateRadioGroupCore { + export type Props = PlaybackRateRadioGroupProps; + export type State = PlaybackRateRadioGroupState; } diff --git a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-data-attrs.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-data-attrs.ts new file mode 100644 index 00000000..b47238e8 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-data-attrs.ts @@ -0,0 +1,11 @@ +import type { StateAttrMap } from '../types'; +import type { PlaybackRateRadioGroupState } from './playback-rate-radio-group-core'; + +export const PlaybackRateRadioGroupDataAttrs = { + /** Current playback rate. */ + rate: 'data-rate', + /** Present when playback rate selection is disabled. */ + disabled: 'data-disabled', + /** Indicates playback rate availability (`available` or `unavailable`). */ + availability: 'data-availability', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts similarity index 66% rename from packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts rename to packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts index 32c70058..3dd48ef8 100644 --- a/packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { MediaPlaybackRateState } from '../../../media/state'; -import type { PlaybackRateMenuState } from '../playback-rate-menu-core'; -import { PlaybackRateMenuCore } from '../playback-rate-menu-core'; +import type { PlaybackRateRadioGroupState } from '../playback-rate-radio-group-core'; +import { PlaybackRateRadioGroupCore } from '../playback-rate-radio-group-core'; function createMediaState(overrides: Partial = {}): MediaPlaybackRateState { return { @@ -13,20 +13,21 @@ function createMediaState(overrides: Partial = {}): Medi }; } -function createState(overrides: Partial = {}): PlaybackRateMenuState { +function createState(overrides: Partial = {}): PlaybackRateRadioGroupState { return { rate: 1, rates: [0.5, 1, 1.5, 2], disabled: false, + availability: 'available', label: '', ...overrides, }; } -describe('PlaybackRateMenuCore', () => { +describe('PlaybackRateRadioGroupCore', () => { describe('getState', () => { it('projects playbackRate and playbackRates', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState({ playbackRate: 1.5, playbackRates: [1, 1.5] }); core.setMedia(media); const state = core.getState(); @@ -36,27 +37,41 @@ describe('PlaybackRateMenuCore', () => { }); it('marks state disabled when no rates are available', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState({ playbackRates: [] }); core.setMedia(media); expect(core.getState().disabled).toBe(true); }); + + it('marks availability unavailable when no rates are available', () => { + const core = new PlaybackRateRadioGroupCore(); + core.setMedia(createMediaState({ playbackRates: [] })); + + expect(core.getState().availability).toBe('unavailable'); + }); + + it('marks availability available when rates exist', () => { + const core = new PlaybackRateRadioGroupCore(); + core.setMedia(createMediaState()); + + expect(core.getState().availability).toBe('available'); + }); }); describe('getLabel', () => { it('returns default label with rate', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5'); }); it('returns custom string label', () => { - const core = new PlaybackRateMenuCore({ label: 'Speed' }); + const core = new PlaybackRateRadioGroupCore({ label: 'Speed' }); expect(core.getLabel(createState())).toBe('Speed'); }); it('returns custom function label', () => { - const core = new PlaybackRateMenuCore({ + const core = new PlaybackRateRadioGroupCore({ label: (state) => `${state.rate}× speed`, }); expect(core.getLabel(createState({ rate: 2 }))).toBe('2× speed'); @@ -65,12 +80,12 @@ describe('PlaybackRateMenuCore', () => { describe('getRateLabel', () => { it('formats rate labels by default', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); expect(core.getRateLabel(1.5)).toBe('1.5×'); }); it('uses a custom formatter', () => { - const core = new PlaybackRateMenuCore({ + const core = new PlaybackRateRadioGroupCore({ formatRate: (rate) => (rate === 1 ? 'Normal' : `${rate}×`), }); @@ -80,13 +95,13 @@ describe('PlaybackRateMenuCore', () => { describe('getAttrs', () => { it('returns aria-label', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const attrs = core.getAttrs(createState({ rate: 1.5 })); expect(attrs['aria-label']).toBe('Playback rate 1.5'); }); it('sets aria-disabled when disabled', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const attrs = core.getAttrs(createState({ disabled: true })); expect(attrs['aria-disabled']).toBe('true'); }); @@ -94,21 +109,21 @@ describe('PlaybackRateMenuCore', () => { describe('select', () => { it('sets a rate from the available list', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState(); core.select(media, 1.5); expect(media.setPlaybackRate).toHaveBeenCalledWith(1.5); }); it('does nothing when disabled', () => { - const core = new PlaybackRateMenuCore({ disabled: true }); + const core = new PlaybackRateRadioGroupCore({ disabled: true }); const media = createMediaState(); core.select(media, 1.5); expect(media.setPlaybackRate).not.toHaveBeenCalled(); }); it('does nothing for unavailable rates', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState(); core.select(media, 3); expect(media.setPlaybackRate).not.toHaveBeenCalled(); @@ -117,14 +132,14 @@ describe('PlaybackRateMenuCore', () => { describe('selectValue', () => { it('sets the rate matching a menu value', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState(); core.selectValue(media, '2'); expect(media.setPlaybackRate).toHaveBeenCalledWith(2); }); it('does nothing for an unknown menu value', () => { - const core = new PlaybackRateMenuCore(); + const core = new PlaybackRateRadioGroupCore(); const media = createMediaState(); core.selectValue(media, '3'); expect(media.setPlaybackRate).not.toHaveBeenCalled(); diff --git a/packages/core/src/dom/store/features/tests/text-track.test.ts b/packages/core/src/dom/store/features/tests/text-track.test.ts index a042dc6b..001c081f 100644 --- a/packages/core/src/dom/store/features/tests/text-track.test.ts +++ b/packages/core/src/dom/store/features/tests/text-track.test.ts @@ -29,8 +29,18 @@ function mockTextTracks(video: HTMLVideoElement, tracks: TextTrack[]): void { }); } -function createMockTrack(kind: TextTrackKind, mode: TextTrackMode = 'disabled'): TextTrack { - return { kind, mode, label: '', language: '' } as TextTrack; +function createMockTrack( + kind: TextTrackKind, + mode: TextTrackMode = 'disabled', + options: { id?: string; label?: string; language?: string } = {} +): TextTrack { + return { + id: options.id ?? '', + kind, + mode, + label: options.label ?? '', + language: options.language ?? '', + } as TextTrack; } describe('textTrackFeature', () => { @@ -151,18 +161,26 @@ describe('textTrackFeature', () => { it('exposes textTrackList for all track kinds', () => { const video = createVideo(); - const subtitlesTrack = { kind: 'subtitles', mode: 'showing', label: 'English', language: 'en' } as TextTrack; - const captionsTrack = { kind: 'captions', mode: 'disabled', label: 'CC', language: 'en' } as TextTrack; - const metadataTrack = createMockTrack('metadata', 'showing'); + const subtitlesTrack = createMockTrack('subtitles', 'showing', { + id: 'subtitles-en', + label: 'English', + language: 'en', + }); + const captionsTrack = createMockTrack('captions', 'disabled', { + id: 'captions-en', + label: 'CC', + language: 'en', + }); + const metadataTrack = createMockTrack('metadata', 'showing', { id: 'metadata-thumbnails' }); mockTextTracks(video, [subtitlesTrack, captionsTrack, metadataTrack]); const store = createStore()(textTrackFeature); store.attach({ media: video, container: null }); expect(store.state.textTrackList).toEqual([ - { kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, - { kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' }, - { kind: 'metadata', label: '', language: '', mode: 'showing' }, + { id: 'subtitles-en', kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { id: 'captions-en', kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' }, + { id: 'metadata-thumbnails', kind: 'metadata', label: '', language: '', mode: 'showing' }, ]); }); @@ -197,6 +215,36 @@ describe('textTrackFeature', () => { expect(store.state.toggleSubtitles()).toBe(false); }); + it('selectSubtitlesTrack() enables one track and disables the others', () => { + const video = createVideo(); + const englishTrack = createMockTrack('subtitles', 'disabled', { id: 'subtitles-en', label: 'English' }); + const spanishTrack = createMockTrack('subtitles', 'disabled', { id: 'subtitles-es', label: 'Spanish' }); + mockTextTracks(video, [englishTrack, spanishTrack]); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + store.state.selectSubtitlesTrack('subtitles-es'); + + expect(englishTrack.mode).toBe('disabled'); + expect(spanishTrack.mode).toBe('showing'); + }); + + it('selectSubtitlesTrack("off") disables all caption tracks', () => { + const video = createVideo(); + const englishTrack = createMockTrack('subtitles', 'showing'); + const spanishTrack = createMockTrack('subtitles', 'disabled'); + mockTextTracks(video, [englishTrack, spanishTrack]); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + store.state.selectSubtitlesTrack('off'); + + expect(englishTrack.mode).toBe('disabled'); + expect(spanishTrack.mode).toBe('disabled'); + }); + it('stops updating after destroy', () => { const video = createVideo(); const store = createStore()(textTrackFeature); diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index cfddf681..e820d5e5 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -5,6 +5,10 @@ import type { TextTrackLike } from '../../../core/media/types'; import { definePlayerFeature } from '../../feature'; import { isMediaTextTrackCapable, isQuerySelectorAllCapable } from '../../media/predicate'; +function getTrackId(track: TextTrackLike, index: number): string { + return track.id || `track:${index}:${track.kind}:${track.language}:${track.label}`; +} + export const textTrackFeature = definePlayerFeature({ name: 'textTrack', state: ({ target }): MediaTextTrackState => ({ @@ -29,6 +33,30 @@ export const textTrackFeature = definePlayerFeature({ return nextShowing; }, + selectSubtitlesTrack(value: string) { + const { media } = target(); + if (!isMediaTextTrackCapable(media)) return; + + const subtitlesTracks = Array.from(media.textTracks) + .map((track, index) => ({ index, track })) + .filter(({ track }) => isCaptionOrSubtitleTrack(track)); + if (!subtitlesTracks.length) return; + + if (value === 'off') { + for (const { track } of subtitlesTracks) { + track.mode = 'disabled'; + } + return; + } + + const active = subtitlesTracks.find(({ index, track }) => getTrackId(track, index) === value); + const track = active?.track; + if (!track) return; + + for (const { track: candidate } of subtitlesTracks) { + candidate.mode = candidate === track ? 'showing' : 'disabled'; + } + }, }), attach({ target, signal, set }) { @@ -53,6 +81,7 @@ export const textTrackFeature = definePlayerFeature({ if (!thumbnailTrack && track.kind === 'metadata' && track.label === 'thumbnails') thumbnailTrack = track; textTrackList.push({ + id: getTrackId(track, i), kind: track.kind as TextTrackKind, label: track.label, language: track.language, diff --git a/packages/core/src/dom/ui/button.ts b/packages/core/src/dom/ui/button.ts index cb5a759a..8714ef91 100644 --- a/packages/core/src/dom/ui/button.ts +++ b/packages/core/src/dom/ui/button.ts @@ -1,7 +1,7 @@ -import type { UIKeyboardEvent } from './event'; +import type { UIEvent, UIKeyboardEvent } from './event'; export interface ButtonOptions { - onActivate: () => void; + onActivate: (event: UIEvent) => void; isDisabled: () => boolean; } @@ -27,7 +27,7 @@ export function createButton(options: ButtonOptions): ButtonProps { event.preventDefault(); return; } - onActivate(); + onActivate(event); }, onPointerDown(event) { @@ -48,7 +48,7 @@ export function createButton(options: ButtonOptions): ButtonProps { if (event.key === 'Enter') { event.preventDefault(); - onActivate(); + onActivate(event); } else if (event.key === ' ') { event.preventDefault(); } @@ -59,7 +59,7 @@ export function createButton(options: ButtonOptions): ButtonProps { if (isDisabled()) return; if (event.key === ' ') { - onActivate(); + onActivate(event); } }, }; diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index dca97eae..b3b0b7e8 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -69,6 +69,8 @@ export function createPopover(options: PopoverOptions): PopoverApi { let popupEl: HTMLElement | null = null; let hoverTimeout: ReturnType | null = null; const capturedPointers = new Set(); + let ignoreNextBlurClose = false; + let blurGuardTimeout: ReturnType | null = null; const layer = createDismissLayer({ transition: options.transition, @@ -112,6 +114,35 @@ export function createPopover(options: PopoverOptions): PopoverApi { return canHover(); } + function clearBlurGuard(): void { + ignoreNextBlurClose = false; + if (blurGuardTimeout !== null) { + clearTimeout(blurGuardTimeout); + blurGuardTimeout = null; + } + } + + function armBlurGuard(): void { + // Trusted pointer gestures can transiently retarget focus to the shadow host + // or body before the click handler runs. Let inside menu actions decide. + ignoreNextBlurClose = true; + if (blurGuardTimeout !== null) clearTimeout(blurGuardTimeout); + blurGuardTimeout = setTimeout(clearBlurGuard, 500); + } + + function consumeBlurGuard(): boolean { + if (!ignoreNextBlurClose) return false; + + clearBlurGuard(); + return true; + } + + function isTriggerDisabled(): boolean { + if (!triggerEl) return false; + if (triggerEl.hasAttribute('disabled')) return true; + return triggerEl.getAttribute('aria-disabled') === 'true'; + } + // --- Open/close --- /** @@ -180,7 +211,12 @@ export function createPopover(options: PopoverOptions): PopoverApi { // the listener is on document, so contains() would always fail. const path = event.composedPath(); - if ((triggerEl && path.includes(triggerEl)) || (popupEl && path.includes(popupEl))) return; + if ((triggerEl && path.includes(triggerEl)) || (popupEl && path.includes(popupEl))) { + armBlurGuard(); + return; + } + + clearBlurGuard(); applyClose('outside-click', event); } @@ -189,6 +225,7 @@ export function createPopover(options: PopoverOptions): PopoverApi { layer.signal.addEventListener('abort', () => { options.group?.()?.close(groupMember); clearHoverTimeout(); + clearBlurGuard(); capturedPointers.clear(); triggerEl = null; popupEl = null; @@ -199,6 +236,7 @@ export function createPopover(options: PopoverOptions): PopoverApi { const triggerProps: PopoverTriggerProps = { onClick(event) { if (!canToggleOnClick()) return; + if (isTriggerDisabled()) return; // During a close animation (open=true, status=ending), treat // the click as a re-open rather than a second close attempt. @@ -293,7 +331,27 @@ export function createPopover(options: PopoverOptions): PopoverApi { return; } - applyClose('blur'); + if (consumeBlurGuard()) return; + + if (relatedTarget !== null) { + applyClose('blur'); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!state.current.active || state.current.status === 'ending' || state.current.status === 'starting') { + return; + } + + const active = document.activeElement; + if (active && (triggerEl?.contains(active) || popupEl?.contains(active))) { + return; + } + + applyClose('blur'); + }); + }); }, }; diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts index ba233c1e..09e50f57 100644 --- a/packages/core/src/dom/ui/popover/tests/popover.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import { createPopupGroup } from '../popup-group'; import { createTestPopover } from './popover-helpers'; +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + describe('createPopover', () => { it('starts closed', () => { const { popover } = createTestPopover(); @@ -162,6 +166,18 @@ describe('createPopover', () => { expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); }); + it('does not open on click when trigger is aria-disabled', () => { + const { popover, onOpenChange } = createTestPopover(); + const trigger = document.createElement('button'); + trigger.setAttribute('aria-disabled', 'true'); + popover.setTriggerElement(trigger); + + popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent); + + expect(popover.input.current.active).toBe(false); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('closes on click when open', () => { const { popover, onOpenChange } = createTestPopover(); @@ -366,6 +382,57 @@ describe('createPopover', () => { }); }); + describe('focusout', () => { + it('keeps the popover open when blur follows an inside pointerdown', () => { + const { popover, onOpenChange } = createTestPopover(); + const popup = document.createElement('div'); + const child = document.createElement('button'); + popup.appendChild(child); + document.body.appendChild(popup); + + popover.setPopupElement(popup); + popover.open(); + flush(); + onOpenChange.mockClear(); + + child.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + popover.popupProps.onFocusOut({ + relatedTarget: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + + expect(onOpenChange).not.toHaveBeenCalledWith(false, expect.anything()); + + popover.destroy(); + popup.remove(); + }); + + it('closes after focus settles outside the popover', async () => { + const { popover, onOpenChange } = createTestPopover(); + const popup = document.createElement('div'); + document.body.appendChild(popup); + + popover.setPopupElement(popup); + popover.open(); + flush(); + onOpenChange.mockClear(); + + popover.popupProps.onFocusOut({ + relatedTarget: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + await nextFrame(); + await nextFrame(); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'blur' })); + + popover.destroy(); + popup.remove(); + }); + }); + describe('destroy', () => { it('prevents further open/close calls', () => { const { popover, onOpenChange } = createTestPopover(); diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index f12eac3f..041dadd0 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -92,10 +92,10 @@ function getTemplateHTML() {
- - - - + + + + - - + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index d682c974..50f4abd4 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -75,9 +75,9 @@ function getTemplateHTML() {
- - - + + + - - + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/audio/minimal-ui.ts b/packages/html/src/define/audio/minimal-ui.ts index fec9ae54..24f47c67 100644 --- a/packages/html/src/define/audio/minimal-ui.ts +++ b/packages/html/src/define/audio/minimal-ui.ts @@ -4,9 +4,8 @@ import { MediaContainerElement } from '../../media/container-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; -import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; +import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; +import { PlaybackRateRadioGroupElement } from '../../ui/playback-rate-radio-group/playback-rate-radio-group-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; @@ -32,9 +31,8 @@ defineMenu(); // Standalone elements. safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); -safeDefine(PlaybackRateOptionsElement); -safeDefine(PlaybackRateMenuTriggerElement); -safeDefine(PlaybackRateMenuElement); +safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateRadioGroupElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); safeDefine(TooltipElement); diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index a2912858..761423f3 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -88,9 +88,9 @@ function getTemplateHTML() {
- - - + + + - - + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 3b1154dc..01c3923f 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -71,9 +71,9 @@ function getTemplateHTML() {
- - - + + + - - + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index 63789684..0624de8c 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -8,9 +8,7 @@ import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; -import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; +import { PlaybackRateRadioGroupElement } from '../../ui/playback-rate-radio-group/playback-rate-radio-group-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; @@ -39,9 +37,7 @@ safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); -safeDefine(PlaybackRateOptionsElement); -safeDefine(PlaybackRateMenuTriggerElement); -safeDefine(PlaybackRateMenuElement); +safeDefine(PlaybackRateRadioGroupElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); safeDefine(TooltipElement); 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 9289cf7f..85947f19 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -9,6 +9,7 @@ import { icon, iconState, inputFeedback, + menu, overlay, popup, poster, @@ -80,10 +81,22 @@ function getTemplateHTML() { - + ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 5b9478e6..0b2aea7f 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -65,10 +65,22 @@ function getTemplateHTML() { - + ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} + + + + + diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts index f7067de7..d91abfe0 100644 --- a/packages/html/src/define/live-video/minimal-ui.ts +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -6,6 +6,7 @@ import { AirPlayButtonElement } from '@/ui/airplay-button/airplay-button-element import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-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'; @@ -23,6 +24,7 @@ import { defineControls, defineErrorDialog, defineInputIndicators, + defineMenu, defineTime, defineTimeSlider, defineVolumeSlider, @@ -43,11 +45,13 @@ defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); +safeDefine(CaptionsRadioGroupElement); safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); safeDefine(GestureElement); diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index ee521b29..c05baa34 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -9,6 +9,7 @@ import { icon, iconState, inputFeedback, + menu, overlay, popup, poster, @@ -82,10 +83,22 @@ function getTemplateHTML() { - + ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index f3bb3713..25d4326a 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -67,10 +67,22 @@ function getTemplateHTML() { - + ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} + + + + + diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index fff8932c..3fb971fe 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -5,6 +5,7 @@ import { AirPlayButtonElement } from '@/ui/airplay-button/airplay-button-element import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-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'; @@ -18,7 +19,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, defineInputIndicators, defineSliders, defineTime } from '../ui/compounds'; +import { + defineControls, + defineErrorDialog, + defineInputIndicators, + defineMenu, + defineSliders, + defineTime, +} from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { LiveVideoPlayerElement } from './player'; @@ -34,11 +42,13 @@ defineErrorDialog(); defineInputIndicators(); defineSliders(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); +safeDefine(CaptionsRadioGroupElement); safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); safeDefine(GestureElement); diff --git a/packages/html/src/define/ui/captions-radio-group.ts b/packages/html/src/define/ui/captions-radio-group.ts new file mode 100644 index 00000000..21254868 --- /dev/null +++ b/packages/html/src/define/ui/captions-radio-group.ts @@ -0,0 +1,10 @@ +import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(CaptionsRadioGroupElement); + +declare global { + interface HTMLElementTagNameMap { + [CaptionsRadioGroupElement.tagName]: CaptionsRadioGroupElement; + } +} diff --git a/packages/html/src/define/ui/compounds.ts b/packages/html/src/define/ui/compounds.ts index c757a4db..07234770 100644 --- a/packages/html/src/define/ui/compounds.ts +++ b/packages/html/src/define/ui/compounds.ts @@ -11,6 +11,7 @@ import { MenuGroupElement } from '../../ui/menu/menu-group-element'; import { MenuGroupLabelElement } from '../../ui/menu/menu-group-label-element'; import { MenuItemElement } from '../../ui/menu/menu-item-element'; import { MenuItemIndicatorElement } from '../../ui/menu/menu-item-indicator-element'; +import { MenuItemValueElement } from '../../ui/menu/menu-item-value-element'; import { MenuRadioGroupElement } from '../../ui/menu/menu-radio-group-element'; import { MenuRadioItemElement } from '../../ui/menu/menu-radio-item-element'; import { MenuSeparatorElement } from '../../ui/menu/menu-separator-element'; @@ -46,6 +47,7 @@ export function defineMenu(): void { safeDefine(MenuBackElement); safeDefine(MenuItemElement); safeDefine(MenuGroupLabelElement); + safeDefine(MenuItemValueElement); safeDefine(MenuSeparatorElement); safeDefine(MenuGroupElement); safeDefine(MenuRadioGroupElement); diff --git a/packages/html/src/define/ui/menu.ts b/packages/html/src/define/ui/menu.ts index 02632a79..ac5c9690 100644 --- a/packages/html/src/define/ui/menu.ts +++ b/packages/html/src/define/ui/menu.ts @@ -5,6 +5,7 @@ import { MenuGroupElement } from '../../ui/menu/menu-group-element'; import { MenuGroupLabelElement } from '../../ui/menu/menu-group-label-element'; import { MenuItemElement } from '../../ui/menu/menu-item-element'; import { MenuItemIndicatorElement } from '../../ui/menu/menu-item-indicator-element'; +import { MenuItemValueElement } from '../../ui/menu/menu-item-value-element'; import { MenuRadioGroupElement } from '../../ui/menu/menu-radio-group-element'; import { MenuRadioItemElement } from '../../ui/menu/menu-radio-item-element'; import { MenuSeparatorElement } from '../../ui/menu/menu-separator-element'; @@ -19,6 +20,7 @@ declare global { [MenuBackElement.tagName]: MenuBackElement; [MenuItemElement.tagName]: MenuItemElement; [MenuGroupLabelElement.tagName]: MenuGroupLabelElement; + [MenuItemValueElement.tagName]: MenuItemValueElement; [MenuSeparatorElement.tagName]: MenuSeparatorElement; [MenuGroupElement.tagName]: MenuGroupElement; [MenuRadioGroupElement.tagName]: MenuRadioGroupElement; diff --git a/packages/html/src/define/ui/playback-rate-menu.ts b/packages/html/src/define/ui/playback-rate-menu.ts deleted file mode 100644 index e9bcd875..00000000 --- a/packages/html/src/define/ui/playback-rate-menu.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; -import { safeDefine } from '../safe-define'; -import { defineMenu } from './compounds'; - -defineMenu(); -safeDefine(PlaybackRateOptionsElement); -safeDefine(PlaybackRateMenuTriggerElement); -safeDefine(PlaybackRateMenuElement); - -declare global { - interface HTMLElementTagNameMap { - [PlaybackRateMenuElement.tagName]: PlaybackRateMenuElement; - [PlaybackRateMenuTriggerElement.tagName]: PlaybackRateMenuTriggerElement; - [PlaybackRateOptionsElement.tagName]: PlaybackRateOptionsElement; - } -} diff --git a/packages/html/src/define/ui/playback-rate-radio-group.ts b/packages/html/src/define/ui/playback-rate-radio-group.ts new file mode 100644 index 00000000..68f5cfa6 --- /dev/null +++ b/packages/html/src/define/ui/playback-rate-radio-group.ts @@ -0,0 +1,10 @@ +import { PlaybackRateRadioGroupElement } from '../../ui/playback-rate-radio-group/playback-rate-radio-group-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(PlaybackRateRadioGroupElement); + +declare global { + interface HTMLElementTagNameMap { + [PlaybackRateRadioGroupElement.tagName]: PlaybackRateRadioGroupElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 229d783d..24a3ac31 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -13,7 +13,6 @@ import { inputFeedback, menu, overlay, - playbackRate, popup, poster, root, @@ -113,22 +112,7 @@ function getTemplateHTML() {
-
- - - - - - - - +
- - - - - - - ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} @@ -121,11 +107,63 @@ function getTemplateHTML() { - - ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} - ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} - - + + + +
+ + Speed + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + + + Captions + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + +
+
+ + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Speed + + + + + + + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Captions + + + + + +
${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 88121216..1e059738 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -6,6 +6,7 @@ import { MediaContainerElement } from '../../media/container-element'; import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-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'; @@ -13,9 +14,8 @@ 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'; -import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; +import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; +import { PlaybackRateRadioGroupElement } from '../../ui/playback-rate-radio-group/playback-rate-radio-group-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { PosterElement } from '../../ui/poster/poster-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; @@ -60,9 +60,9 @@ safeDefine(HotkeyElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); -safeDefine(PlaybackRateOptionsElement); -safeDefine(PlaybackRateMenuTriggerElement); -safeDefine(PlaybackRateMenuElement); +safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateRadioGroupElement); +safeDefine(CaptionsRadioGroupElement); safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(SeekButtonElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 03dc4459..74c136dd 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -13,7 +13,6 @@ import { inputFeedback, menu, overlay, - playbackRate, popup, poster, root, @@ -109,21 +108,7 @@ function getTemplateHTML() {
-
- - - - - - - +
- - - - - - - ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} @@ -117,11 +103,63 @@ function getTemplateHTML() { - - ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} - ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} - - + + + +
+ + Speed + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + + + Captions + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + +
+
+ + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Speed + + + + + + + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Captions + + + + + +
${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index a87ae021..c6deea1e 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -5,6 +5,7 @@ import { MediaContainerElement } from '../../media/container-element'; import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-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'; @@ -14,9 +15,7 @@ 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'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; -import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; +import { PlaybackRateRadioGroupElement } from '../../ui/playback-rate-radio-group/playback-rate-radio-group-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { PosterElement } from '../../ui/poster/poster-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; @@ -61,9 +60,8 @@ safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); -safeDefine(PlaybackRateOptionsElement); -safeDefine(PlaybackRateMenuTriggerElement); -safeDefine(PlaybackRateMenuElement); +safeDefine(PlaybackRateRadioGroupElement); +safeDefine(CaptionsRadioGroupElement); safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(SeekButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index ef0f3c4e..5d2e208b 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -33,6 +33,7 @@ export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-el export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context'; export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element'; export { CaptionsButtonElement } from './ui/captions-button/captions-button-element'; +export { CaptionsRadioGroupElement } from './ui/captions-radio-group/captions-radio-group-element'; export { CastButtonElement } from './ui/cast-button/cast-button-element'; export { ContextPartElement, type PartContextValue } from './ui/context-part-element'; export { ControlsElement } from './ui/controls/controls-element'; @@ -62,6 +63,7 @@ export { MenuGroupElement } from './ui/menu/menu-group-element'; export { MenuGroupLabelElement } from './ui/menu/menu-group-label-element'; export { MenuItemElement } from './ui/menu/menu-item-element'; export { MenuItemIndicatorElement } from './ui/menu/menu-item-indicator-element'; +export { MenuItemValueElement } from './ui/menu/menu-item-value-element'; export { MenuRadioGroupElement } from './ui/menu/menu-radio-group-element'; export { MenuRadioItemElement } from './ui/menu/menu-radio-item-element'; export { MenuSeparatorElement } from './ui/menu/menu-separator-element'; @@ -70,9 +72,7 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element'; export { PiPButtonElement } from './ui/pip-button/pip-button-element'; export { PlayButtonElement } from './ui/play-button/play-button-element'; export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element'; -export { PlaybackRateMenuElement } from './ui/playback-rate-menu/playback-rate-menu-element'; -export { PlaybackRateMenuTriggerElement } from './ui/playback-rate-menu/playback-rate-menu-trigger-element'; -export { PlaybackRateOptionsElement } from './ui/playback-rate-menu/playback-rate-options-element'; +export { PlaybackRateRadioGroupElement } from './ui/playback-rate-radio-group/playback-rate-radio-group-element'; export { PopoverElement } from './ui/popover/popover-element'; export { PosterElement } from './ui/poster/poster-element'; export { SeekButtonElement } from './ui/seek-button/seek-button-element'; diff --git a/packages/html/src/media/background-video/index.ts b/packages/html/src/media/background-video/index.ts index dde9058d..2b2da522 100644 --- a/packages/html/src/media/background-video/index.ts +++ b/packages/html/src/media/background-video/index.ts @@ -68,12 +68,11 @@ export class BackgroundVideo extends MediaAttachMixin(HTMLElement) { } get target(): HTMLVideoElement | null { - return ( - this.querySelector(':scope > [slot=media]') ?? - this.querySelector('video') ?? - this.shadowRoot?.querySelector('video') ?? - null - ); + const slotted = this.querySelector(':scope > [slot=media]'); + if (slotted instanceof HTMLVideoElement) return slotted; + + const video = this.querySelector('video') ?? this.shadowRoot?.querySelector('video'); + return video instanceof HTMLVideoElement ? video : null; } } diff --git a/packages/html/src/ui/captions-button/captions-button-element.ts b/packages/html/src/ui/captions-button/captions-button-element.ts index f13e8601..1b9585d5 100644 --- a/packages/html/src/ui/captions-button/captions-button-element.ts +++ b/packages/html/src/ui/captions-button/captions-button-element.ts @@ -1,19 +1,100 @@ import { CaptionsButtonCore, CaptionsButtonDataAttrs, type MediaTextTrackState } from '@videojs/core'; -import { selectTextTrack } from '@videojs/core/dom'; +import { applyElementProps, selectTextTrack, type UIEvent } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; +import { toggleCommandTarget } from '../command-for'; import { MediaButtonElement } from '../media-button-element'; +function getCaptionTrackCount(state: MediaTextTrackState): number { + return state.textTrackList.filter(isCaptionOrSubtitleTrack).length; +} + export class CaptionsButtonElement extends MediaButtonElement { static readonly tagName = 'media-captions-button'; + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + commandfor: { type: String }, + menuFor: { type: String, attribute: 'menu-for' }, + } satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor' | 'menuFor'>; + + commandfor: string | undefined = undefined; + menuFor: string | undefined = undefined; + #defaultCommandfor: string | undefined = undefined; + protected readonly core = new CaptionsButtonCore(); protected readonly stateAttrMap = CaptionsButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack); protected override readonly hotkeyAction = 'toggleSubtitles'; - protected activate(state: MediaTextTrackState): void { + override connectedCallback(): void { + super.connectedCallback(); + if (this.commandfor && this.commandfor !== this.menuFor) { + this.#defaultCommandfor = this.commandfor; + } + } + + protected activate(state: MediaTextTrackState, event?: UIEvent): void { + if (this.menuFor && getCaptionTrackCount(state) > 1) { + if (event instanceof KeyboardEvent) { + toggleCommandTarget(this, this.menuFor); + } + return; + } + this.core.toggle(state); } + + protected override getIsButtonDisabled(): boolean { + const media = this.mediaState.value; + if (super.getIsButtonDisabled()) return true; + if (media && getCaptionTrackCount(media) === 0) return true; + return false; + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + + if (changed.has('commandfor') && this.commandfor !== this.menuFor) { + this.#defaultCommandfor = this.commandfor; + } + + if (changed.has('commandfor') || changed.has('menuFor')) { + this.#syncCommandFor(); + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.mediaState.value; + if (!media) return; + + this.#syncCommandFor(media); + + if (this.menuFor && getCaptionTrackCount(media) > 1) { + applyElementProps(this, { + 'aria-disabled': this.getIsButtonDisabled() ? 'true' : undefined, + }); + } + } + + #syncCommandFor(media?: MediaTextTrackState): void { + const state = media ?? this.mediaState.value; + const target = state && this.menuFor && getCaptionTrackCount(state) > 1 ? this.menuFor : this.#defaultCommandfor; + + if (target) { + this.setAttribute('commandfor', target); + } else { + this.removeAttribute('commandfor'); + } + } +} + +export namespace CaptionsButtonElement { + export type State = CaptionsButtonCore.State; } diff --git a/packages/html/src/ui/captions-button/tests/captions-button-element.test.ts b/packages/html/src/ui/captions-button/tests/captions-button-element.test.ts new file mode 100644 index 00000000..b10cad91 --- /dev/null +++ b/packages/html/src/ui/captions-button/tests/captions-button-element.test.ts @@ -0,0 +1,111 @@ +import type { MediaTextTrackState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { CaptionsButtonElement } from '../captions-button-element'; + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +function createTextTrackStore(textTrackList: MediaTextTrackState['textTrackList']): AnyPlayerStore { + return createStore()({ + name: 'textTrack', + state: () => ({ + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + textTrackList, + subtitlesShowing: false, + toggleSubtitles: vi.fn(), + selectSubtitlesTrack: vi.fn(), + }), + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + readonly #provider = new ContextProvider(this, { context: playerContext }); + + setStore(store: AnyPlayerStore): void { + this.#provider.setValue(store); + } +} + +defineElement(CaptionsButtonElement.tagName, CaptionsButtonElement); +defineElement('test-captions-button-player', TestPlayerProviderElement); + +describe('CaptionsButtonElement', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('restores the configured command target when captions no longer need a menu', async () => { + const provider = document.createElement('test-captions-button-player') as TestPlayerProviderElement; + const button = document.createElement(CaptionsButtonElement.tagName) as CaptionsButtonElement; + const textTrackList: MediaTextTrackState['textTrackList'] = [ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ]; + + button.commandfor = 'captions-toggle'; + button.menuFor = 'captions-menu'; + provider.setStore(createTextTrackStore(textTrackList)); + provider.append(button); + document.body.append(provider); + + await button.updateComplete; + await waitForAssertion(() => { + expect(button.getAttribute('commandfor')).toBe('captions-menu'); + }); + + button.commandfor = 'captions-fallback'; + await button.updateComplete; + await waitForAssertion(() => { + expect(button.getAttribute('commandfor')).toBe('captions-menu'); + }); + + textTrackList.pop(); + button.requestUpdate(); + + await waitForAssertion(() => { + expect(button.getAttribute('commandfor')).toBe('captions-fallback'); + }); + }); + + it('maps the menu-for attribute to menuFor', async () => { + const button = document.createElement(CaptionsButtonElement.tagName) as CaptionsButtonElement; + + button.setAttribute('menu-for', 'captions-menu'); + document.body.append(button); + + await button.updateComplete; + + expect(button.menuFor).toBe('captions-menu'); + }); +}); diff --git a/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts b/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts new file mode 100644 index 00000000..9cbecf6c --- /dev/null +++ b/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts @@ -0,0 +1,148 @@ +import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore, CaptionsRadioGroupDataAttrs } from '@videojs/core'; +import { applyStateDataAttrs, logMissingFeature, selectTextTrack } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element'; +import { MenuRadioGroupElement } from '../menu/menu-radio-group-element'; +import { MenuRadioItemElement } from '../menu/menu-radio-item-element'; + +export class CaptionsRadioGroupElement extends MenuRadioGroupElement { + static override readonly tagName = 'media-captions-radio-group'; + + static override properties = { + ...MenuRadioGroupElement.properties, + disabled: { type: Boolean }, + label: { type: String }, + } satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>; + + disabled = false; + label = ''; + + readonly #core = new CaptionsRadioGroupCore(); + readonly #mediaState = new PlayerController(this, playerContext, selectTextTrack); + + #tracksKey = ''; + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.#disconnect = new AbortController(); + this.addEventListener('value-change', this.#handleValueChange, { signal: this.#disconnect.signal }); + + if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { + logMissingFeature(this.localName, this.#mediaState.displayName); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + protected override update(changed: PropertyValues): void { + const media = this.#mediaState.value; + let state: CaptionsRadioGroupCore.State | null = null; + + if (media) { + this.#core.setProps({ disabled: this.disabled }); + this.#core.setMedia(media); + state = this.#core.getState(); + + this.value = state.value; + this.label = this.label || 'Captions'; + this.#syncContent(state); + } + + super.update(changed); + + if (state) applyStateDataAttrs(this, state, CaptionsRadioGroupDataAttrs); + } + + #syncContent(state: CaptionsRadioGroupCore.State): void { + const template = this.#getTemplate(); + const templateKey = template?.innerHTML ?? ''; + const tracksKey = `${state.tracks.map((track) => track.value).join('|')}::${templateKey}`; + + if (tracksKey !== this.#tracksKey) { + this.#tracksKey = tracksKey; + + for (const child of [...this.children]) { + if (child instanceof HTMLTemplateElement) continue; + child.remove(); + } + + this.append(this.#createItem(CAPTIONS_OFF_VALUE, 'Off', template)); + this.append(...state.tracks.map((track) => this.#createItem(track.value, track.label, template))); + } + + for (const item of this.querySelectorAll(MenuRadioItemElement.tagName)) { + const checked = item.value === this.value; + + item.disabled = state.disabled; + + for (const indicator of item.querySelectorAll(MenuItemIndicatorElement.tagName)) { + indicator.checked = checked; + } + } + } + + #createItem(value: string, label: string, template: HTMLTemplateElement | null): MenuRadioItemElement { + const item = this.#createItemFromTemplate(template); + + item.value = value; + item.setAttribute('data-track', value); + this.#setLabel(item, label); + + return item; + } + + #createItemFromTemplate(template: HTMLTemplateElement | null): MenuRadioItemElement { + if (!template) return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement; + + const fragment = template.content.cloneNode(true) as DocumentFragment; + const root = fragment.firstElementChild; + + if (!root || root.localName !== MenuRadioItemElement.tagName || root.nextElementSibling) { + return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement; + } + + return root as MenuRadioItemElement; + } + + #setLabel(item: MenuRadioItemElement, label: string): void { + const labelPart = item.querySelector('[data-part~="label"]'); + + if (labelPart) { + labelPart.textContent = label; + } else { + item.textContent = label; + } + } + + #getTemplate(): HTMLTemplateElement | null { + for (const child of this.children) { + if (child instanceof HTMLTemplateElement) return child; + } + + return null; + } + + #handleValueChange = (event: Event): void => { + if (event.target !== this) return; + + const media = this.#mediaState.value; + if (!media) return; + + const { value } = (event as CustomEvent<{ value: string }>).detail; + this.#core.selectValue(media, value); + }; +} + +export namespace CaptionsRadioGroupElement { + export type State = CaptionsRadioGroupCore.State; +} diff --git a/packages/html/src/ui/command-for.ts b/packages/html/src/ui/command-for.ts new file mode 100644 index 00000000..52104ced --- /dev/null +++ b/packages/html/src/ui/command-for.ts @@ -0,0 +1,12 @@ +/** Toggle a popup host linked via `commandfor` (menu, popover, etc.). */ +export function toggleCommandTarget(host: HTMLElement, commandfor: string): void { + const root = host.getRootNode() as Document | ShadowRoot; + const target = + ('getElementById' in root ? root.getElementById(commandfor) : null) ?? + root.querySelector(`#${CSS.escape(commandfor)}`); + + if (!target || !('open' in target)) return; + + const popup = target as { open: boolean }; + popup.open = !popup.open; +} diff --git a/packages/html/src/ui/controls/tests/controls-element.test.ts b/packages/html/src/ui/controls/tests/controls-element.test.ts index 211cd967..3bd6d5f7 100644 --- a/packages/html/src/ui/controls/tests/controls-element.test.ts +++ b/packages/html/src/ui/controls/tests/controls-element.test.ts @@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { playerContext } from '../../../player/context'; import { MediaElement } from '../../media-element'; import { MenuElement } from '../../menu/menu-element'; -import { PlaybackRateMenuElement } from '../../playback-rate-menu/playback-rate-menu-element'; import { PopoverElement } from '../../popover/popover-element'; import { TooltipElement } from '../../tooltip/tooltip-element'; import { ControlsElement } from '../controls-element'; @@ -98,12 +97,12 @@ afterEach(() => { }); describe('ControlsElement', () => { - it('closes owned popovers, menus, playback-rate menus, and tooltips when controls hide', async () => { + it('closes owned popovers, menus, and tooltips when controls hide', async () => { const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement; const controls = createDefinedElement(ControlsElement); const popover = createDefinedElement(PopoverElement); const menu = createDefinedElement(MenuElement); - const playbackRateMenu = createDefinedElement(PlaybackRateMenuElement); + const playbackRateMenu = createDefinedElement(MenuElement); const tooltip = createDefinedElement(TooltipElement); const popoverClose = vi.spyOn(popover, 'close'); const menuClose = vi.spyOn(menu, 'close'); diff --git a/packages/html/src/ui/media-button-element.ts b/packages/html/src/ui/media-button-element.ts index d10b43b8..e1574b7c 100644 --- a/packages/html/src/ui/media-button-element.ts +++ b/packages/html/src/ui/media-button-element.ts @@ -5,7 +5,13 @@ import type { MediaButtonComponent, StateAttrMap, } from '@videojs/core'; -import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature } from '@videojs/core/dom'; +import { + applyElementProps, + applyStateDataAttrs, + createButton, + logMissingFeature, + type UIEvent, +} from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import type { State } from '@videojs/store'; @@ -27,7 +33,15 @@ export abstract class MediaButtonElement exte protected abstract readonly stateAttrMap: StateAttrMap>; protected abstract readonly mediaState: PlayerController | undefined>; - protected abstract activate(state: InferMediaState): void; + protected abstract activate(state: InferMediaState, event?: UIEvent): void; + + protected getIsButtonDisabled(): boolean { + return this.disabled || !this.mediaState.value; + } + + protected handleActivate(event: UIEvent): void { + this.activate(this.mediaState.value!, event); + } /** Override to set the hotkey action name for `aria-keyshortcuts`. */ protected readonly hotkeyAction: string | undefined = undefined; @@ -50,8 +64,8 @@ export abstract class MediaButtonElement exte this.#disconnect = new AbortController(); const buttonProps = createButton({ - onActivate: () => this.activate(this.mediaState.value!), - isDisabled: () => this.disabled || !this.mediaState.value, + onActivate: (event) => this.handleActivate(event), + isDisabled: () => this.getIsButtonDisabled(), }); applyElementProps(this, buttonProps, { signal: this.#disconnect.signal }); diff --git a/packages/html/src/ui/menu/context.ts b/packages/html/src/ui/menu/context.ts index 1408298c..cbddd3f9 100644 --- a/packages/html/src/ui/menu/context.ts +++ b/packages/html/src/ui/menu/context.ts @@ -2,6 +2,8 @@ import type { MenuState, StateAttrMap } from '@videojs/core'; import type { MenuApi, NavigationState } from '@videojs/core/dom'; import { createContext } from '@videojs/element/context'; +import type { MenuItemSettingType } from './menu-item-type'; + export interface MenuContextValue { menu: MenuApi; state: MenuState; @@ -20,10 +22,20 @@ export interface MenuGroupContextValue { registerLabel: (id: string) => () => void; } +export interface MenuItemSettingContextValue { + type: MenuItemSettingType; + label: string; + availability: 'available' | 'unavailable'; +} + const MENU_CONTEXT_KEY = Symbol('@videojs/menu'); const MENU_RADIO_GROUP_CONTEXT_KEY = Symbol('@videojs/menu-radio-group'); const MENU_GROUP_CONTEXT_KEY = Symbol('@videojs/menu-group'); +const MENU_ITEM_SETTING_CONTEXT_KEY = Symbol('@videojs/menu-item-setting'); export const menuContext = createContext(MENU_CONTEXT_KEY); export const menuRadioGroupContext = createContext(MENU_RADIO_GROUP_CONTEXT_KEY); export const menuGroupContext = createContext(MENU_GROUP_CONTEXT_KEY); +export const menuItemSettingContext = createContext( + MENU_ITEM_SETTING_CONTEXT_KEY +); diff --git a/packages/html/src/ui/menu/get-menu-item-setting-state.ts b/packages/html/src/ui/menu/get-menu-item-setting-state.ts new file mode 100644 index 00000000..2ba3b651 --- /dev/null +++ b/packages/html/src/ui/menu/get-menu-item-setting-state.ts @@ -0,0 +1,42 @@ +import type { MediaPlaybackRateState, MediaTextTrackState } from '@videojs/core'; +import { CAPTIONS_OFF_VALUE, type CaptionsRadioGroupCore, type PlaybackRateRadioGroupCore } from '@videojs/core'; + +import type { MenuItemSettingType } from './menu-item-type'; + +export interface MenuItemSettingState { + label: string; + availability: 'available' | 'unavailable'; +} + +export function getMenuItemSettingState( + type: MenuItemSettingType, + cores: { + playbackRate: PlaybackRateRadioGroupCore; + captions: CaptionsRadioGroupCore; + }, + media: MediaPlaybackRateState | MediaTextTrackState +): MenuItemSettingState { + if (type === 'playback-rate') { + cores.playbackRate.setMedia(media as MediaPlaybackRateState); + const state = cores.playbackRate.getState(); + + return { + label: cores.playbackRate.getRateLabel(state.rate), + availability: state.availability, + }; + } + + cores.captions.setMedia(media as MediaTextTrackState); + const state = cores.captions.getState(); + + if (state.value === CAPTIONS_OFF_VALUE) { + return { label: 'Off', availability: state.availability }; + } + + const track = state.tracks.find((candidate) => candidate.value === state.value); + + return { + label: track?.label ?? 'Off', + availability: state.availability, + }; +} diff --git a/packages/html/src/ui/menu/menu-item-element.ts b/packages/html/src/ui/menu/menu-item-element.ts index 958eddd6..fd6143b5 100644 --- a/packages/html/src/ui/menu/menu-item-element.ts +++ b/packages/html/src/ui/menu/menu-item-element.ts @@ -1,9 +1,18 @@ -import { applyElementProps, completeMenuItemSelection } from '@videojs/core/dom'; +import { CaptionsRadioGroupCore, PlaybackRateRadioGroupCore } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { applyElementProps, completeMenuItemSelection, selectPlaybackRate, selectTextTrack } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; -import { ContextConsumer } from '@videojs/element/context'; +import { ContextConsumer, ContextProvider } from '@videojs/element/context'; +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; import { MediaElement } from '../media-element'; -import { menuContext } from './context'; +import { menuContext, menuItemSettingContext } from './context'; +import { getMenuItemSettingState } from './get-menu-item-setting-state'; +import type { MenuItemSettingType } from './menu-item-type'; + +type PlaybackRateState = ReturnType; +type TextTrackState = ReturnType; export class MenuItemElement extends MediaElement { static readonly tagName = 'media-menu-item'; @@ -11,16 +20,25 @@ export class MenuItemElement extends MediaElement { static override properties = { disabled: { type: Boolean }, commandfor: { type: String }, - } satisfies PropertyDeclarationMap<'disabled' | 'commandfor'>; + type: { type: String }, + } satisfies PropertyDeclarationMap<'disabled' | 'commandfor' | 'type'>; disabled = false; /** ID of a nested `` to open when this item is activated. */ commandfor: string | undefined = undefined; + /** Setting kind for submenu triggers (`playback-rate` or `captions`). */ + type: MenuItemSettingType | null = null; + readonly #playbackRateCore = new PlaybackRateRadioGroupCore(); + readonly #captionsCore = new CaptionsRadioGroupCore(); + #playbackRateValue: PlayerController | null = null; + #captionsValue: PlayerController | null = null; readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + readonly #settingProvider = new ContextProvider(this, { context: menuItemSettingContext }); #disconnect: AbortController | null = null; #registered = false; + #settingUnavailable = false; #cleanupRegistration: (() => void) | null = null; override connectedCallback(): void { @@ -41,6 +59,8 @@ export class MenuItemElement extends MediaElement { protected override update(_changed: PropertyValues): void { super.update(_changed); + this.#syncMenuItemSetting(); + const ctx = this.#ctx.value; if (!ctx || !this.#disconnect) return; @@ -54,12 +74,10 @@ export class MenuItemElement extends MediaElement { { onClick: (event: MouseEvent) => { const currentCtx = this.#ctx.value; - if (!currentCtx || this.disabled) return; + if (!currentCtx || this.#isDisabled()) return; const target = this.commandfor; if (target) { - // Push the linked submenu — use this element's id as triggerId - // (ensure the element has an id for focus restoration). currentCtx.menu.push(target, this.id); } else { this.dispatchEvent(new CustomEvent('select', { bubbles: true })); @@ -69,7 +87,7 @@ export class MenuItemElement extends MediaElement { }, onKeyDown: (event: KeyboardEvent) => { const currentCtx = this.#ctx.value; - if (!currentCtx || this.disabled || event.key !== 'ArrowRight') return; + if (!currentCtx || this.#isDisabled() || event.key !== 'ArrowRight') return; const target = this.commandfor; if (!target) return; @@ -79,7 +97,7 @@ export class MenuItemElement extends MediaElement { }, onPointerenter: () => { const currentCtx = this.#ctx.value; - if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false }); + if (!this.#isDisabled()) currentCtx?.menu.highlight(this, { focus: false }); }, }, { signal: this.#disconnect.signal } @@ -93,7 +111,7 @@ export class MenuItemElement extends MediaElement { applyElementProps(this, { role: 'menuitem', - 'aria-disabled': this.disabled ? 'true' : undefined, + 'aria-disabled': this.#isDisabled() ? 'true' : undefined, ...(hasSubmenu && { 'aria-haspopup': 'menu', 'aria-expanded': isExpanded ? 'true' : 'false', @@ -101,4 +119,47 @@ export class MenuItemElement extends MediaElement { }), }); } + + #syncMenuItemSetting(): void { + if (!this.type || !this.commandfor) { + this.#setSettingUnavailable(false); + this.#settingProvider.setValue(undefined); + return; + } + + const value = this.#getSettingValue(this.type); + if (!value) { + this.#setSettingUnavailable(false); + this.#settingProvider.setValue(undefined); + return; + } + + const setting = getMenuItemSettingState( + this.type, + { playbackRate: this.#playbackRateCore, captions: this.#captionsCore }, + value + ); + + applyElementProps(this, { 'data-availability': setting.availability }); + this.#setSettingUnavailable(setting.availability !== 'available'); + this.#settingProvider.setValue({ type: this.type, ...setting }); + } + + #isDisabled(): boolean { + return this.disabled || this.#settingUnavailable; + } + + #setSettingUnavailable(unavailable: boolean): void { + this.#settingUnavailable = unavailable; + } + + #getSettingValue(type: MenuItemSettingType): PlaybackRateState | TextTrackState | undefined { + if (type === 'playback-rate') { + this.#playbackRateValue ??= new PlayerController(this, playerContext, selectPlaybackRate); + return this.#playbackRateValue.value; + } + + this.#captionsValue ??= new PlayerController(this, playerContext, selectTextTrack); + return this.#captionsValue.value; + } } diff --git a/packages/html/src/ui/menu/menu-item-type.ts b/packages/html/src/ui/menu/menu-item-type.ts new file mode 100644 index 00000000..1707a129 --- /dev/null +++ b/packages/html/src/ui/menu/menu-item-type.ts @@ -0,0 +1 @@ +export type MenuItemSettingType = 'playback-rate' | 'captions'; diff --git a/packages/html/src/ui/menu/menu-item-value-element.ts b/packages/html/src/ui/menu/menu-item-value-element.ts new file mode 100644 index 00000000..0e92d588 --- /dev/null +++ b/packages/html/src/ui/menu/menu-item-value-element.ts @@ -0,0 +1,22 @@ +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuItemSettingContext } from './context'; + +export class MenuItemValueElement extends MediaElement { + static readonly tagName = 'media-menu-item-value'; + + readonly #ctx = new ContextConsumer(this, { context: menuItemSettingContext, subscribe: true }); + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('aria-live', 'off'); + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + this.textContent = this.#ctx.value?.label ?? ''; + } +} diff --git a/packages/html/src/ui/menu/tests/menu-item-element.test.ts b/packages/html/src/ui/menu/tests/menu-item-element.test.ts new file mode 100644 index 00000000..fb6830da --- /dev/null +++ b/packages/html/src/ui/menu/tests/menu-item-element.test.ts @@ -0,0 +1,105 @@ +import type { MediaTextTrackState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { MenuElement } from '../menu-element'; +import { MenuItemElement } from '../menu-item-element'; + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +function createTextTrackStore({ + textTrackList = [], + subtitlesShowing = false, + selectSubtitlesTrack = vi.fn(), +}: { + textTrackList?: MediaTextTrackState['textTrackList'] | undefined; + subtitlesShowing?: boolean | undefined; + selectSubtitlesTrack?: MediaTextTrackState['selectSubtitlesTrack'] | undefined; +} = {}): AnyPlayerStore { + return createStore()({ + name: 'textTrack', + state: () => ({ + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + textTrackList, + subtitlesShowing, + toggleSubtitles: vi.fn(), + selectSubtitlesTrack, + }), + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store: AnyPlayerStore = createTextTrackStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext }); + + override connectedCallback(): void { + this.#provider.setValue(this.store); + super.connectedCallback(); + } + + setStore(store: AnyPlayerStore): void { + this.store = store; + this.#provider.setValue(store); + } +} + +defineElement(MenuItemElement.tagName, MenuItemElement); +defineElement(MenuElement.tagName, MenuElement); +defineElement('test-menu-item-player', TestPlayerProviderElement); + +describe('MenuItemElement', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('marks submenu triggers unavailable when captions type has no tracks', async () => { + const provider = document.createElement('test-menu-item-player') as TestPlayerProviderElement; + const menu = document.createElement(MenuElement.tagName) as MenuElement; + const menuItem = document.createElement(MenuItemElement.tagName) as MenuItemElement; + + provider.setStore(createTextTrackStore({ textTrackList: [] })); + menuItem.type = 'captions'; + menuItem.commandfor = 'settings-captions-menu'; + menu.append(menuItem); + provider.append(menu); + document.body.append(provider); + + await menu.updateComplete; + await menuItem.updateComplete; + await waitForAssertion(() => { + expect(menuItem.getAttribute('data-availability')).toBe('unavailable'); + expect(menuItem.getAttribute('aria-disabled')).toBe('true'); + }); + }); +}); diff --git a/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts new file mode 100644 index 00000000..a598a970 --- /dev/null +++ b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts @@ -0,0 +1,180 @@ +import type { MediaPlaybackRateState, MediaTextTrackState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { MenuItemElement } from '../menu-item-element'; +import { MenuItemValueElement } from '../menu-item-value-element'; + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +function createPlaybackRateStore({ + playbackRates = [0.5, 1, 1.5, 2], + playbackRate = 1.5, +}: { + playbackRates?: readonly number[] | undefined; + playbackRate?: number | undefined; +} = {}): AnyPlayerStore { + return createStore()({ + name: 'playbackRate', + state: () => ({ + playbackRates, + playbackRate, + setPlaybackRate: vi.fn(), + }), + }) as unknown as AnyPlayerStore; +} + +function createTextTrackStore({ + textTrackList = [], + subtitlesShowing = false, + selectSubtitlesTrack = vi.fn(), +}: { + textTrackList?: MediaTextTrackState['textTrackList'] | undefined; + subtitlesShowing?: boolean | undefined; + selectSubtitlesTrack?: MediaTextTrackState['selectSubtitlesTrack'] | undefined; +} = {}): AnyPlayerStore { + return createStore()({ + name: 'textTrack', + state: () => ({ + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + textTrackList, + subtitlesShowing, + toggleSubtitles: vi.fn(), + selectSubtitlesTrack, + }), + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store: AnyPlayerStore = createPlaybackRateStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext }); + + override connectedCallback(): void { + this.#provider.setValue(this.store); + super.connectedCallback(); + } + + setStore(store: AnyPlayerStore): void { + this.store = store; + this.#provider.setValue(store); + } +} + +defineElement(MenuItemElement.tagName, MenuItemElement); +defineElement(MenuItemValueElement.tagName, MenuItemValueElement); +defineElement('test-menu-item-value-player', TestPlayerProviderElement); + +function setup( + store: AnyPlayerStore, + type: MenuItemElement['type'] +): { + menuItem: MenuItemElement; + value: MenuItemValueElement; +} { + const provider = document.createElement('test-menu-item-value-player') as TestPlayerProviderElement; + const menuItem = document.createElement(MenuItemElement.tagName) as MenuItemElement; + const value = document.createElement(MenuItemValueElement.tagName) as MenuItemValueElement; + + provider.setStore(store); + menuItem.type = type; + menuItem.commandfor = 'settings-submenu'; + menuItem.append(value); + provider.append(menuItem); + document.body.append(provider); + + return { menuItem, value }; +} + +describe('MenuItemValueElement', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders the current playback rate label from menu item context', async () => { + const { value } = setup(createPlaybackRateStore({ playbackRate: 1.5 }), 'playback-rate'); + + await value.updateComplete; + await waitForAssertion(() => { + expect(value.textContent).toBe('1.5×'); + }); + }); + + it('renders Off when captions are disabled', async () => { + const { value } = setup( + createTextTrackStore({ + textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }], + }), + 'captions' + ); + + await value.updateComplete; + await waitForAssertion(() => { + expect(value.textContent).toBe('Off'); + }); + }); + + it('renders the active caption track label', async () => { + const { value } = setup( + createTextTrackStore({ + textTrackList: [ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + subtitlesShowing: true, + }), + 'captions' + ); + + await value.updateComplete; + await waitForAssertion(() => { + expect(value.textContent).toBe('English'); + }); + }); + + it('clears the label when setting context is removed', async () => { + const { menuItem, value } = setup(createPlaybackRateStore({ playbackRate: 1.5 }), 'playback-rate'); + + await value.updateComplete; + await waitForAssertion(() => { + expect(value.textContent).toBe('1.5×'); + }); + + menuItem.type = null; + await menuItem.updateComplete; + + await waitForAssertion(() => { + expect(value.textContent).toBe(''); + }); + }); +}); diff --git a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts index 630a848a..e50c2b2c 100644 --- a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts +++ b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts @@ -1,18 +1,68 @@ import { type MediaPlaybackRateState, PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; -import { selectPlaybackRate } from '@videojs/core/dom'; +import { applyElementProps, selectPlaybackRate, type UIEvent } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; +import { toggleCommandTarget } from '../command-for'; import { MediaButtonElement } from '../media-button-element'; export class PlaybackRateButtonElement extends MediaButtonElement { static readonly tagName = 'media-playback-rate-button'; + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + commandfor: { type: String }, + } satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>; + + commandfor: string | undefined = undefined; + protected readonly core = new PlaybackRateButtonCore(); protected readonly stateAttrMap = PlaybackRateButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectPlaybackRate); - protected activate(state: MediaPlaybackRateState): void { + protected activate(state: MediaPlaybackRateState, event?: UIEvent): void { + if (this.commandfor) { + if (event instanceof KeyboardEvent) { + toggleCommandTarget(this, this.commandfor); + } + return; + } this.core.cycle(state); } + + protected override getIsButtonDisabled(): boolean { + const media = this.mediaState.value; + if (super.getIsButtonDisabled()) return true; + if (this.commandfor && media && media.playbackRates.length === 0) return true; + return false; + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + + if (changed.has('commandfor')) { + if (this.commandfor) { + this.setAttribute('commandfor', this.commandfor); + } else { + this.removeAttribute('commandfor'); + } + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.mediaState.value; + if (!media || !this.commandfor) return; + + applyElementProps(this, { + 'aria-disabled': this.getIsButtonDisabled() ? 'true' : undefined, + }); + } +} + +export namespace PlaybackRateButtonElement { + export type State = PlaybackRateButtonCore.State; } diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts deleted file mode 100644 index 8bf6dc74..00000000 --- a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; -import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - -import { playerContext } from '../../player/context'; -import { PlayerController } from '../../player/player-controller'; -import { MenuElement } from '../menu/menu-element'; - -export class PlaybackRateMenuElement extends MenuElement { - static override readonly tagName = 'media-playback-rate-menu'; - - static override properties = { - ...MenuElement.properties, - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap< - | 'open' - | 'defaultOpen' - | 'side' - | 'align' - | 'closeOnEscape' - | 'closeOnOutsideClick' - | 'boundary' - | 'label' - | 'disabled' - >; - - label = ''; - disabled = false; - override align: MenuElement['align'] = 'center'; - formatRate = PlaybackRateMenuCore.defaultProps.formatRate; - - readonly #core = new PlaybackRateMenuCore(); - readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); - - override connectedCallback(): void { - super.connectedCallback(); - if (this.destroyed) return; - - if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { - logMissingFeature(this.localName, this.#mediaState.displayName); - } - } - - protected override update(changed: PropertyValues): void { - super.update(changed); - - const media = this.#mediaState.value; - if (!media) return; - - this.#core.setProps(this); - this.#core.setMedia(media); - const state = this.#core.getState(); - - applyElementProps(this, this.#core.getAttrs(state)); - applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); - } -} - -export namespace PlaybackRateMenuElement { - export type State = PlaybackRateMenuCore.State; -} diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts deleted file mode 100644 index d61c0528..00000000 --- a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; -import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - -import { playerContext } from '../../player/context'; -import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; - -export class PlaybackRateMenuTriggerElement extends MediaElement { - static readonly tagName = 'media-playback-rate-menu-trigger'; - - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - commandfor: { type: String }, - } satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>; - - label = ''; - disabled = false; - commandfor: string | undefined = undefined; - formatRate = PlaybackRateMenuCore.defaultProps.formatRate; - - readonly #core = new PlaybackRateMenuCore(); - readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - if (this.destroyed) return; - - this.#disconnect = new AbortController(); - applyElementProps( - this, - { - onClick: this.#handleClick, - onKeyDown: this.#handleKeyDown, - }, - { signal: this.#disconnect.signal } - ); - - if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { - logMissingFeature(this.localName, this.#mediaState.displayName); - } - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this.#disconnect?.abort(); - this.#disconnect = null; - } - - /** Returns the trigger's current label derived from media state. */ - getLabel(): string | undefined { - return this.#core.state.current.label || undefined; - } - - protected override update(changed: PropertyValues): void { - super.update(changed); - - const media = this.#mediaState.value; - if (!media) return; - - this.#core.setProps(this); - this.#core.setMedia(media); - const state = this.#core.getState(); - - applyElementProps(this, { - role: 'button', - tabIndex: 0, - ...this.#core.getAttrs(state), - }); - applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); - } - - #handleClick = (event: MouseEvent): void => { - if (this.#mediaState.value && !this.#core.state.current.disabled) return; - - event.preventDefault(); - event.stopImmediatePropagation(); - }; - - #handleKeyDown = (event: KeyboardEvent): void => { - if (event.target !== event.currentTarget) return; - - if (!this.#mediaState.value || this.#core.state.current.disabled) { - if (event.key !== 'Tab') event.preventDefault(); - return; - } - - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this.click(); - } - }; -} - -export namespace PlaybackRateMenuTriggerElement { - export type State = PlaybackRateMenuCore.State; -} diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts b/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts similarity index 86% rename from packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts rename to packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts index 32056fe2..dfd6e810 100644 --- a/packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts +++ b/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts @@ -1,4 +1,4 @@ -import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; +import { PlaybackRateRadioGroupCore, PlaybackRateRadioGroupDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; @@ -8,8 +8,8 @@ import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element'; import { MenuRadioGroupElement } from '../menu/menu-radio-group-element'; import { MenuRadioItemElement } from '../menu/menu-radio-item-element'; -export class PlaybackRateOptionsElement extends MenuRadioGroupElement { - static override readonly tagName = 'media-playback-rate-options'; +export class PlaybackRateRadioGroupElement extends MenuRadioGroupElement { + static override readonly tagName = 'media-playback-rate-radio-group'; static override properties = { ...MenuRadioGroupElement.properties, @@ -17,9 +17,9 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement { } satisfies PropertyDeclarationMap<'value' | 'disabled'>; disabled = false; - formatRate = PlaybackRateMenuCore.defaultProps.formatRate; + formatRate = PlaybackRateRadioGroupCore.defaultProps.formatRate; - readonly #core = new PlaybackRateMenuCore(); + readonly #core = new PlaybackRateRadioGroupCore(); readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); #ratesKey = ''; @@ -45,7 +45,7 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement { protected override update(changed: PropertyValues): void { const media = this.#mediaState.value; - let state: PlaybackRateMenuCore.State | null = null; + let state: PlaybackRateRadioGroupCore.State | null = null; if (media) { this.#core.setProps({ formatRate: this.formatRate, disabled: this.disabled }); @@ -62,10 +62,10 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement { super.update(changed); - if (state) applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); + if (state) applyStateDataAttrs(this, state, PlaybackRateRadioGroupDataAttrs); } - #syncContent(state: PlaybackRateMenuCore.State): void { + #syncContent(state: PlaybackRateRadioGroupCore.State): void { const template = this.#getTemplate(); const templateKey = template?.innerHTML ?? ''; const ratesKey = `${state.rates.join('|')}::${templateKey}`; @@ -145,6 +145,6 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement { }; } -export namespace PlaybackRateOptionsElement { - export type State = PlaybackRateMenuCore.State; +export namespace PlaybackRateRadioGroupElement { + export type State = PlaybackRateRadioGroupCore.State; } diff --git a/packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts b/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts similarity index 73% rename from packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts rename to packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts index e32279d8..6d268de2 100644 --- a/packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts +++ b/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts @@ -6,12 +6,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { playerContext } from '../../../player/context'; import { MediaElement } from '../../media-element'; +import { MenuElement } from '../../menu/menu-element'; import { MenuItemIndicatorElement } from '../../menu/menu-item-indicator-element'; import { MenuRadioGroupElement } from '../../menu/menu-radio-group-element'; import { MenuRadioItemElement } from '../../menu/menu-radio-item-element'; -import { PlaybackRateMenuElement } from '../playback-rate-menu-element'; -import { PlaybackRateMenuTriggerElement } from '../playback-rate-menu-trigger-element'; -import { PlaybackRateOptionsElement } from '../playback-rate-options-element'; +import { PlaybackRateButtonElement } from '../../playback-rate-button/playback-rate-button-element'; +import { PlaybackRateRadioGroupElement } from '../playback-rate-radio-group-element'; let tagCounter = 0; @@ -88,10 +88,12 @@ class TestPlayerProviderElement extends MediaElement { } } +defineElement(MenuElement.tagName, MenuElement); defineElement(MenuRadioGroupElement.tagName, MenuRadioGroupElement); defineElement(MenuRadioItemElement.tagName, MenuRadioItemElement); defineElement(MenuItemIndicatorElement.tagName, MenuItemIndicatorElement); -defineElement(PlaybackRateOptionsElement.tagName, PlaybackRateOptionsElement); +defineElement(PlaybackRateRadioGroupElement.tagName, PlaybackRateRadioGroupElement); +defineElement(PlaybackRateButtonElement.tagName, PlaybackRateButtonElement); defineElement('test-playback-rate-player', TestPlayerProviderElement); function setup({ @@ -107,13 +109,13 @@ function setup({ } = {}) { const store = createPlaybackRateStore({ playbackRates, playbackRate, setPlaybackRate }); const provider = document.createElement('test-playback-rate-player') as TestPlayerProviderElement; - const trigger = createElement(PlaybackRateMenuTriggerElement); - const menu = createElement(PlaybackRateMenuElement); - const options = createElement(PlaybackRateOptionsElement); + const trigger = createElement(PlaybackRateButtonElement); + const menu = createElement(MenuElement); + const options = createElement(PlaybackRateRadioGroupElement); provider.setStore(store); - trigger.commandfor = 'playback-rate-menu'; menu.id = 'playback-rate-menu'; + trigger.setAttribute('commandfor', 'playback-rate-menu'); if (template) { const templateElement = document.createElement('template'); @@ -129,15 +131,15 @@ function setup({ } async function waitForMenu( - menu: PlaybackRateMenuElement, - trigger?: PlaybackRateMenuTriggerElement, - options?: PlaybackRateOptionsElement + menu: MenuElement, + trigger?: PlaybackRateButtonElement, + options?: PlaybackRateRadioGroupElement ): Promise { await trigger?.updateComplete; await menu.updateComplete; await options?.updateComplete; - const group = menu.querySelector(PlaybackRateOptionsElement.tagName); + const group = menu.querySelector(PlaybackRateRadioGroupElement.tagName); await group?.updateComplete; const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; @@ -151,7 +153,7 @@ afterEach(() => { document.body.innerHTML = ''; }); -describe('PlaybackRateMenuElement', () => { +describe('PlaybackRateRadioGroupElement', () => { it('renders radio items from the available playback rates', async () => { const { menu, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 }); @@ -163,8 +165,6 @@ describe('PlaybackRateMenuElement', () => { await waitForAssertion(() => { expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false']); }); - expect(menu.getAttribute('aria-label')).toBe('Playback rate 1.25'); - expect(menu.getAttribute('data-rate')).toBe('1.25'); }); it('renders radio items from a template', async () => { @@ -183,15 +183,6 @@ describe('PlaybackRateMenuElement', () => { expect(indicators.map((indicator) => indicator.checked)).toEqual([false, false, true, false]); }); - it('center aligns the root popup by default', async () => { - const { menu, trigger } = setup(); - - await waitForMenu(menu, trigger); - - expect(menu.align).toBe('center'); - expect(menu.getAttribute('data-align')).toBe('center'); - }); - it('sets the selected playback rate', async () => { const setPlaybackRate = vi.fn(); const { menu, trigger } = setup({ setPlaybackRate }); @@ -208,8 +199,8 @@ describe('PlaybackRateMenuElement', () => { }); }); -describe('PlaybackRateMenuTriggerElement', () => { - it('renders a dynamic trigger from the current playback rate', async () => { +describe('PlaybackRateButtonElement', () => { + it('renders the current playback rate on the trigger button', async () => { const { trigger } = setup({ playbackRate: 2 }); await trigger.updateComplete; @@ -219,17 +210,58 @@ describe('PlaybackRateMenuTriggerElement', () => { expect(trigger.getAttribute('data-rate')).toBe('2'); }); - it('prevents activation when there are no playback rates', async () => { + it('does not cycle when commandfor is set', async () => { + const setPlaybackRate = vi.fn(); + const { trigger, store } = setup({ playbackRate: 1, setPlaybackRate }); + + await trigger.updateComplete; + + trigger.click(); + + expect(setPlaybackRate).not.toHaveBeenCalled(); + expect((store.state as MediaPlaybackRateState).playbackRate).toBe(1); + }); + + it('opens the linked menu on Enter when commandfor is set', async () => { + const { menu, trigger } = setup(); + + await waitForMenu(menu, trigger); + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + + await waitForAssertion(() => { + expect(menu.open).toBe(true); + }); + }); + + it('opens the linked menu on Space when commandfor is set', async () => { + const { menu, trigger } = setup(); + + await waitForMenu(menu, trigger); + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + trigger.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true })); + + await waitForAssertion(() => { + expect(menu.open).toBe(true); + }); + }); + + it('disables the trigger when there are no playback rates', async () => { const { trigger } = setup({ playbackRates: [] }); await trigger.updateComplete; - const onClick = vi.fn(); - trigger.addEventListener('click', onClick); + expect(trigger.getAttribute('aria-disabled')).toBe('true'); + }); + + it('does not open the linked menu when disabled and clicked', async () => { + const { menu, trigger } = setup({ playbackRates: [] }); + + await waitForMenu(menu, trigger); + trigger.click(); - expect(trigger.getAttribute('aria-disabled')).toBe('true'); - expect(trigger.hasAttribute('data-disabled')).toBe(true); - expect(onClick).not.toHaveBeenCalled(); + expect(menu.open).toBe(false); }); }); diff --git a/packages/icons/src/assets/default/gear.svg b/packages/icons/src/assets/default/gear.svg new file mode 100644 index 00000000..33f4ee40 --- /dev/null +++ b/packages/icons/src/assets/default/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/minimal/gear.svg b/packages/icons/src/assets/minimal/gear.svg new file mode 100644 index 00000000..0b02e07e --- /dev/null +++ b/packages/icons/src/assets/minimal/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b7c86e33..29fd117e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -33,6 +33,12 @@ export { AirPlayButton, type AirPlayButtonProps } from './ui/airplay-button/airp export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog'; export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator'; export { CaptionsButton, type CaptionsButtonProps } from './ui/captions-button/captions-button'; +export { + type CaptionsOption, + type CaptionsOptionsProps, + type CaptionsOptionsResult, + useCaptionsOptions, +} from './ui/captions-radio-group'; export { CastButton, type CastButtonProps } from './ui/cast-button/cast-button'; export { Controls } from './ui/controls'; export type { ControlsGroupProps } from './ui/controls/controls-group'; @@ -52,13 +58,13 @@ export { Menu, type MenuContextValue, useMenuContext, useOptionalMenuContext } f export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; -export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; export { - PlaybackRateMenu, - type PlaybackRateMenuOption, - type PlaybackRateMenuResult, - usePlaybackRateMenu, -} from './ui/playback-rate-menu'; + type PlaybackRateOption, + type PlaybackRateOptionsProps, + type PlaybackRateOptionsResult, + usePlaybackRateOptions, +} from './ui/playback-rate'; +export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; 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'; diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index 0f558209..b5fc7b7f 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -32,7 +32,8 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; +import { usePlaybackRateOptions } from '@/ui/playback-rate'; +import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; import { Time } from '@/ui/time'; @@ -120,8 +121,11 @@ function VolumePopover(): ReactNode { ); } -function PlaybackRateMenuItems(): ReactNode { - const { options, setValue, value } = usePlaybackRateMenu(); +function PlaybackRateRadioGroup(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + const { options, setValue, value } = state; return ( @@ -137,6 +141,18 @@ function PlaybackRateMenuItems(): ReactNode { ); } +function PlaybackRateTrigger(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + return ( + } />} + /> + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNode { @@ -225,12 +241,12 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
- - } /> - - - - + + + + + +
diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index d716e5f4..13530604 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -16,7 +16,8 @@ import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; +import { usePlaybackRateOptions } from '@/ui/playback-rate'; +import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; import { StatusAnnouncer } from '@/ui/status-announcer'; @@ -69,8 +70,11 @@ function VolumePopover(): ReactNode { ); } -function PlaybackRateMenuItems(): ReactNode { - const { options, setValue, value } = usePlaybackRateMenu(); +function PlaybackRateRadioGroup(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + const { options, setValue, value } = state; return ( @@ -86,6 +90,18 @@ function PlaybackRateMenuItems(): ReactNode { ); } +function PlaybackRateTrigger(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + return ( + } />} + /> + ); +} + export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { const { children, className, ...rest } = props; @@ -172,12 +188,12 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
- - } /> - - - - + + + + + +
diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index 3708d560..e99678e6 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -33,7 +33,8 @@ import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; +import { usePlaybackRateOptions } from '@/ui/playback-rate'; +import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; import { StatusAnnouncer } from '@/ui/status-announcer'; @@ -122,8 +123,11 @@ function VolumePopover(): ReactNode { ); } -function PlaybackRateMenuItems(): ReactNode { - const { options, setValue, value } = usePlaybackRateMenu(); +function PlaybackRateRadioGroup(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + const { options, setValue, value } = state; return ( @@ -139,6 +143,18 @@ function PlaybackRateMenuItems(): ReactNode { ); } +function PlaybackRateTrigger(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + return ( + } />} + /> + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { @@ -223,12 +239,12 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
- - } /> - - - - + + + + + +
diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index a650f203..2db8920b 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -16,7 +16,8 @@ import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; +import { usePlaybackRateOptions } from '@/ui/playback-rate'; +import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; import { StatusAnnouncer } from '@/ui/status-announcer'; @@ -69,8 +70,11 @@ function VolumePopover(): ReactNode { ); } -function PlaybackRateMenuItems(): ReactNode { - const { options, setValue, value } = usePlaybackRateMenu(); +function PlaybackRateRadioGroup(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + const { options, setValue, value } = state; return ( @@ -86,6 +90,18 @@ function PlaybackRateMenuItems(): ReactNode { ); } +function PlaybackRateTrigger(): ReactNode { + const state = usePlaybackRateOptions(); + if (!state) return null; + + return ( + } />} + /> + ); +} + export function AudioSkin(props: AudioSkinProps): ReactNode { const { children, className, ...rest } = props; @@ -168,12 +184,12 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
- - } /> - - - - + + + + + +
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 a171433d..5c714205 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -8,6 +8,7 @@ import { icon, iconState, inputFeedback, + menu, overlay, popup, poster, @@ -24,6 +25,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, FullscreenEnterIcon, FullscreenExitIcon, PauseIcon, @@ -40,6 +42,7 @@ import { Container, usePlayer } from '@/player/context'; import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; +import { useCaptionsOptions } from '@/ui/captions-radio-group'; import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; @@ -47,6 +50,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; @@ -134,6 +138,60 @@ function VolumePopover(): ReactNode { ); } +function CaptionsTrigger(): ReactNode { + const captions = useCaptionsOptions(); + if (!captions) return null; + + const { disabled } = captions; + + if (!captions.showMenu) { + return ( + + }> + + + + } + /> + + + ); + } + + return ( + + }> + + + + } + /> + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + + + ); +} + export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): ReactNode { const { children, className, poster: posterProp, ...rest } = props; @@ -187,7 +245,7 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + @@ -198,17 +256,7 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps):
- - }> - - - - } - /> - - + } /> - + @@ -243,7 +291,7 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + @@ -255,7 +303,7 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - +
diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index 691898e8..f0270d1f 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -8,6 +8,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, FullscreenEnterIcon, FullscreenExitIcon, PauseIcon, @@ -24,6 +25,7 @@ import { Container, usePlayer } from '@/player/context'; import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; +import { useCaptionsOptions } from '@/ui/captions-radio-group'; import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; @@ -31,6 +33,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; @@ -95,6 +98,69 @@ function VolumePopover(): ReactNode { * the start and end button groups so they sit at opposite edges of the * control bar. */ +function CaptionsTrigger(): ReactNode { + const captions = useCaptionsOptions(); + if (!captions) return null; + + const { disabled } = captions; + + if (!captions.showMenu) { + return ( + + }> + + + + } + /> + + + ); + } + + return ( + + }> + + + + } + /> + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + + + ); +} + export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNode { const { children, className, poster, ...rest } = props; @@ -152,17 +218,7 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod
- - }> - - - - } - /> - - + + }> + + + + } + /> + + + ); + } + + return ( + + }> + + + + } + /> + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + + + ); +} + export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { const { children, className, poster: posterProp, ...rest } = props; @@ -189,7 +247,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + @@ -200,17 +258,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode {
- - }> - - - - } - /> - - + } /> - + @@ -245,7 +293,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + @@ -257,7 +305,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - +
diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index 8dd347e4..71bb1f2f 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -8,6 +8,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, FullscreenEnterIcon, FullscreenExitIcon, PauseIcon, @@ -24,6 +25,7 @@ import { Container, usePlayer } from '@/player/context'; import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; +import { useCaptionsOptions } from '@/ui/captions-radio-group'; import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; @@ -31,6 +33,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; @@ -94,6 +97,62 @@ function VolumePopover(): ReactNode { * flexible spacer stretches between the start and end button groups so they * sit at opposite edges of the control bar. */ +function CaptionsTrigger(): ReactNode { + const captions = useCaptionsOptions(); + if (!captions) return null; + + const { disabled } = captions; + + if (!captions.showMenu) { + return ( + + }> + + + + } + /> + + + ); + } + + const { options, setValue, value } = captions; + + return ( + + }> + + + + } + /> + + + {options.map((option) => ( + + {option.label} + + + + + ))} + + + + ); +} + export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { const { children, className, poster, ...rest } = props; @@ -153,17 +212,7 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode {
- - }> - - - - } - /> - - + ; +} + +function SettingsMenu(): ReactNode { + const playbackRate = usePlaybackRateOptions(); + const captions = useCaptionsOptions(); + const hasPlaybackRate = playbackRate?.state.availability === 'available'; + const hasCaptions = captions?.state.availability === 'available'; + + if (!hasPlaybackRate && !hasCaptions) return null; return ( - - {options.map((option) => ( - - {option.label} - - - - - ))} - + + } + > + + + + +
+ {hasPlaybackRate && playbackRate ? ( + + ( +
+ Speed + + + + +
+ )} + /> + + + + Speed + + + {playbackRate.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + {hasCaptions && captions ? ( + + ( +
+ Captions + + + + +
+ )} + /> + + + + Captions + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} +
+
+
+
); } @@ -278,36 +386,19 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
- +
- +
- - } /> - - - - - - - }> - - - - } - /> - - + ; +} + +function SettingsMenu(): ReactNode { + const playbackRate = usePlaybackRateOptions(); + const captions = useCaptionsOptions(); + const hasPlaybackRate = playbackRate?.state.availability === 'available'; + const hasCaptions = captions?.state.availability === 'available'; + + if (!hasPlaybackRate && !hasCaptions) return null; return ( - - {options.map((option) => ( - - {option.label} - - - - - ))} - + + }> + + + + +
+ {hasPlaybackRate && playbackRate ? ( + + ( +
+ Speed + + + + +
+ )} + /> + + + + Speed + + + {playbackRate.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + {hasCaptions && captions ? ( + + ( +
+ Captions + + + + +
+ )} + /> + + + + Captions + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} +
+
+
+
); } @@ -214,34 +319,16 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
- - +
- - } /> - - - - - - - }> - - - - } - /> - - + ; +} + +function SettingsMenu(): ReactNode { + const playbackRate = usePlaybackRateOptions(); + const captions = useCaptionsOptions(); + const hasPlaybackRate = playbackRate?.state.availability === 'available'; + const hasCaptions = captions?.state.availability === 'available'; + + if (!hasPlaybackRate && !hasCaptions) return null; return ( - - {options.map((option) => ( - - {option.label} - - - - - ))} - + + } + > + + + + +
+ {hasPlaybackRate && playbackRate ? ( + + ( +
+ Speed + + + + +
+ )} + /> + + + + Speed + + + {playbackRate.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + {hasCaptions && captions ? ( + + ( +
+ Captions + + + + +
+ )} + /> + + + + Captions + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} +
+
+
+
); } @@ -273,37 +381,20 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } />
- +
- +
- - } /> - - - - - - - }> - - - - } - /> - - + ; +} + +function SettingsMenu(): ReactNode { + const playbackRate = usePlaybackRateOptions(); + const captions = useCaptionsOptions(); + const hasPlaybackRate = playbackRate?.state.availability === 'available'; + const hasCaptions = captions?.state.availability === 'available'; + + if (!hasPlaybackRate && !hasCaptions) return null; return ( - - {options.map((option) => ( - - {option.label} - - - - - ))} - + + }> + + + + +
+ {hasPlaybackRate && playbackRate ? ( + + ( +
+ Speed + + + + +
+ )} + /> + + + + Speed + + + {playbackRate.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + {hasCaptions && captions ? ( + + ( +
+ Captions + + + + +
+ )} + /> + + + + Captions + + + {captions.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} +
+
+
+
); } @@ -209,35 +314,17 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
- - +
- - } /> - - - - - - - }> - - - - } - /> - - + true), +}: { + textTrackList?: readonly { kind: string; label: string; language: string; mode: string }[]; + toggleSubtitles?: () => boolean; +} = {}) { + const { Wrapper } = createPlayerWrapper({ + textTrackList, + subtitlesShowing: true, + selectSubtitlesTrack: vi.fn(), + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + toggleSubtitles, + }); + + render( + + } /> + Captions + , + { wrapper: Wrapper } + ); + + return { toggleSubtitles }; +} + +describe('CaptionsButton', () => { + it('does not toggle captions when rendered inside Menu.Trigger with multiple tracks', () => { + const { toggleSubtitles } = renderCaptionsTrigger(); + + fireEvent.click(screen.getByTestId('trigger')); + + expect(toggleSubtitles).not.toHaveBeenCalled(); + }); + + it('still toggles captions outside Menu.Trigger', () => { + const toggleSubtitles = vi.fn(() => true); + const { Wrapper } = createPlayerWrapper({ + textTrackList: [ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' }, + ], + subtitlesShowing: true, + selectSubtitlesTrack: vi.fn(), + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + toggleSubtitles, + }); + + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByTestId('trigger')); + + expect(toggleSubtitles).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/ui/captions-radio-group/index.ts b/packages/react/src/ui/captions-radio-group/index.ts new file mode 100644 index 00000000..b32397b2 --- /dev/null +++ b/packages/react/src/ui/captions-radio-group/index.ts @@ -0,0 +1,6 @@ +export { + type CaptionsOption, + type CaptionsOptionsProps, + type CaptionsOptionsResult, + useCaptionsOptions, +} from './use-captions-options'; diff --git a/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx b/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx new file mode 100644 index 00000000..a0a169cd --- /dev/null +++ b/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { CAPTIONS_OFF_VALUE } from '@videojs/core'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPlayerWrapper } from '../../../testing/mocks'; +import { Menu } from '../../menu'; +import { useCaptionsOptions } from '../use-captions-options'; + +afterEach(cleanup); + +function renderCaptionsMenu({ + textTrackList = [ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }, + { kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'showing' }, + ] as const, + subtitlesShowing = true, + selectSubtitlesTrack = vi.fn(), +}: { + textTrackList?: readonly { kind: string; label: string; language: string; mode: string }[]; + subtitlesShowing?: boolean; + selectSubtitlesTrack?: (value: string) => void; +} = {}) { + const { Wrapper } = createPlayerWrapper({ + textTrackList, + subtitlesShowing, + selectSubtitlesTrack, + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + toggleSubtitles: vi.fn(), + }); + + render( + + + + + , + { wrapper: Wrapper } + ); + + return { selectSubtitlesTrack }; +} + +function CaptionsRadioGroup(): ReactNode { + const captions = useCaptionsOptions(); + if (!captions?.showMenu) return null; + + const { options, setValue, value } = captions; + + return ( + + {options.map((option) => ( + + {option.label} + + ))} + + ); +} + +describe('useCaptionsOptions', () => { + it('renders radio items for off and available tracks', () => { + renderCaptionsMenu(); + + expect(screen.getByRole('menuitemradio', { name: 'Off' }).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByRole('menuitemradio', { name: 'English' }).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByRole('menuitemradio', { name: 'Spanish' }).getAttribute('aria-checked')).toBe('true'); + }); + + it('center aligns the popup by default', () => { + renderCaptionsMenu(); + + expect(screen.getByTestId('content').getAttribute('data-align')).toBe('center'); + }); + + it('selects a captions track', () => { + const selectSubtitlesTrack = vi.fn(); + renderCaptionsMenu({ selectSubtitlesTrack }); + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'English' })); + + expect(selectSubtitlesTrack).toHaveBeenCalledWith('0'); + }); + + it('turns captions off', () => { + const selectSubtitlesTrack = vi.fn(); + renderCaptionsMenu({ selectSubtitlesTrack }); + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Off' })); + + expect(selectSubtitlesTrack).toHaveBeenCalledWith(CAPTIONS_OFF_VALUE); + }); +}); diff --git a/packages/react/src/ui/captions-radio-group/use-captions-options.ts b/packages/react/src/ui/captions-radio-group/use-captions-options.ts new file mode 100644 index 00000000..0a7374d4 --- /dev/null +++ b/packages/react/src/ui/captions-radio-group/use-captions-options.ts @@ -0,0 +1,68 @@ +'use client'; + +import { + CAPTIONS_OFF_VALUE, + type CaptionsRadioGroupCore, + CaptionsRadioGroupCore as CaptionsRadioGroupCoreClass, +} from '@videojs/core'; +import { logMissingFeature, selectTextTrack } from '@videojs/core/dom'; +import { useCallback, useState } from 'react'; + +import { usePlayer } from '../../player/context'; + +export interface CaptionsOptionsProps extends CaptionsRadioGroupCore.Props {} + +export interface CaptionsOption { + value: string; + label: string; + disabled: boolean; +} + +export interface CaptionsOptionsResult { + state: CaptionsRadioGroupCore.State; + value: string; + options: CaptionsOption[]; + disabled: boolean; + showMenu: boolean; + setValue: (value: string) => void; +} + +export function useCaptionsOptions(props?: CaptionsOptionsProps): CaptionsOptionsResult | null { + const media = usePlayer(selectTextTrack); + const [core] = useState(() => new CaptionsRadioGroupCoreClass()); + + core.setProps(props ?? {}); + + const setValue = useCallback((value: string) => core.selectValue(media!, value), [core, media]); + + if (!media) { + if (__DEV__) logMissingFeature('useCaptionsOptions', selectTextTrack.displayName ?? 'textTrack'); + return null; + } + + core.setMedia(media); + const state = core.getState(); + const showMenu = state.tracks.length > 1; + + return { + state, + value: state.value, + options: [ + { value: CAPTIONS_OFF_VALUE, label: 'Off', disabled: state.disabled }, + ...state.tracks.map((track) => ({ + value: track.value, + label: track.label, + disabled: state.disabled, + })), + ], + disabled: state.disabled, + showMenu, + setValue, + }; +} + +export namespace useCaptionsOptions { + export type Props = CaptionsOptionsProps; + export type Result = CaptionsOptionsResult; + export type Option = CaptionsOption; +} diff --git a/packages/react/src/ui/create-media-button.tsx b/packages/react/src/ui/create-media-button.tsx index a07ba1c8..80c11b72 100644 --- a/packages/react/src/ui/create-media-button.tsx +++ b/packages/react/src/ui/create-media-button.tsx @@ -3,6 +3,7 @@ import type { InferComponentState, InferMediaState, MediaButtonComponent, StateAttrMap } from '@videojs/core'; import { logMissingFeature } from '@videojs/core/dom'; import type { Selector } from '@videojs/store'; +import { isUndefined } from '@videojs/utils/predicate'; import type { ForwardedRef, ForwardRefExoticComponent, RefAttributes } from 'react'; import { forwardRef, useLayoutEffect, useState } from 'react'; @@ -11,6 +12,7 @@ import type { renderElement as renderElementFn } from '../utils/use-render'; import { renderElement } from '../utils/use-render'; import { useButton } from './hooks/use-button'; import { useAriaKeyShortcuts } from './hotkey/use-aria-key-shortcuts'; +import { useOptionalMenuTriggerChildContext } from './menu/context'; import { useOptionalTooltipContext } from './tooltip/context'; interface MediaButtonConfig> { @@ -49,10 +51,16 @@ export function createMediaButton, P } const tooltipCtx = useOptionalTooltipContext(); + const menuTriggerChild = useOptionalMenuTriggerChildContext(); const feature = usePlayer(selector); const shortcuts = useAriaKeyShortcuts(hotkeyAction); const [core] = useState(() => new CoreClass()); + + if (corePropKeys.has('menuTrigger') && isUndefined(coreProps.menuTrigger)) { + coreProps.menuTrigger = menuTriggerChild; + } + core.setProps(coreProps); const { getButtonProps, buttonRef } = useButton({ diff --git a/packages/react/src/ui/hooks/use-button.ts b/packages/react/src/ui/hooks/use-button.ts index 55c11c95..4ebbfdd0 100644 --- a/packages/react/src/ui/hooks/use-button.ts +++ b/packages/react/src/ui/hooks/use-button.ts @@ -1,13 +1,13 @@ 'use client'; -import { createButton } from '@videojs/core/dom'; +import { createButton, type UIEvent } from '@videojs/core/dom'; import type { ComponentPropsWithRef, Ref } from 'react'; import { useCallback } from 'react'; import { mergeProps } from '../../utils/merge-props'; export interface UseButtonParameters { displayName: string; - onActivate: () => void; + onActivate: (event: UIEvent) => void; isDisabled: () => boolean; } diff --git a/packages/react/src/ui/menu/context.tsx b/packages/react/src/ui/menu/context.tsx index 1ba7e4d8..74d43875 100644 --- a/packages/react/src/ui/menu/context.tsx +++ b/packages/react/src/ui/menu/context.tsx @@ -4,6 +4,8 @@ import type { MenuCore, MenuState, StateAttrMap } from '@videojs/core'; import type { MediaContainer, MenuApi, PositioningBoundary } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; +import type { MenuItemSettingType } from './menu-item-type'; + export interface MenuContextValue { core: MenuCore; menu: MenuApi; @@ -94,3 +96,40 @@ export function useMenuRadioGroupContext(): MenuRadioGroupContextValue { if (!ctx) throw new Error('Menu.RadioItem must be used within a Menu.RadioGroup'); return ctx; } + +// --------------------------------------------------------------------------- +// Root trigger render context — provided by Menu.Trigger for render children. +// --------------------------------------------------------------------------- + +const MenuTriggerChildContext = createContext(false); + +export const MenuTriggerChildContextProvider = MenuTriggerChildContext.Provider; + +export function useOptionalMenuTriggerChildContext(): boolean { + return useContext(MenuTriggerChildContext); +} + +// --------------------------------------------------------------------------- +// Menu item setting context — provided by Menu.Item or Menu.Trigger when `type` +// is set; consumed by Menu.ItemValue. +// --------------------------------------------------------------------------- + +export interface MenuItemSettingContextValue { + type: MenuItemSettingType; + label: string; + availability: 'available' | 'unavailable'; +} + +const MenuItemSettingContext = createContext(null); + +export const MenuItemSettingContextProvider = MenuItemSettingContext.Provider; + +export function useMenuItemSettingContext(): MenuItemSettingContextValue { + const ctx = useContext(MenuItemSettingContext); + if (!ctx) throw new Error('Menu.ItemValue must be used within a Menu.Item or Menu.Trigger with `type` set'); + return ctx; +} + +export function useOptionalMenuItemSettingContext(): MenuItemSettingContextValue | null { + return useContext(MenuItemSettingContext); +} diff --git a/packages/react/src/ui/menu/index.parts.ts b/packages/react/src/ui/menu/index.parts.ts index 3c6561ad..368391db 100644 --- a/packages/react/src/ui/menu/index.parts.ts +++ b/packages/react/src/ui/menu/index.parts.ts @@ -11,6 +11,7 @@ export { MenuItemIndicator as ItemIndicator, type MenuItemIndicatorProps as ItemIndicatorProps, } from './menu-item-indicator'; +export { MenuItemValue as ItemValue, type MenuItemValueProps as ItemValueProps } from './menu-item-value'; export { MenuRadioGroup as RadioGroup, type MenuRadioGroupProps as RadioGroupProps } from './menu-radio-group'; export { MenuRadioItem as RadioItem, type MenuRadioItemProps as RadioItemProps } from './menu-radio-item'; export { MenuRoot as Root, type MenuRootProps as RootProps } from './menu-root'; diff --git a/packages/react/src/ui/menu/menu-item-setting-provider.tsx b/packages/react/src/ui/menu/menu-item-setting-provider.tsx new file mode 100644 index 00000000..a46b9773 --- /dev/null +++ b/packages/react/src/ui/menu/menu-item-setting-provider.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { CAPTIONS_OFF_VALUE } from '@videojs/core'; +import type { ReactNode } from 'react'; + +import { useCaptionsOptions } from '../captions-radio-group/use-captions-options'; +import { usePlaybackRateOptions } from '../playback-rate/use-playback-rate-options'; +import { MenuItemSettingContextProvider } from './context'; +import type { MenuItemSettingType } from './menu-item-type'; + +export interface MenuItemSettingProviderProps { + type: MenuItemSettingType; + children: ReactNode; +} + +function PlaybackRateMenuItemSettingProvider({ children }: { children: ReactNode }): ReactNode { + const playbackRate = usePlaybackRateOptions(); + if (!playbackRate) return children; + + const { state, options, value } = playbackRate; + const label = options.find((option) => option.value === value)?.label ?? ''; + + return ( + + {children} + + ); +} + +function CaptionsMenuItemSettingProvider({ children }: { children: ReactNode }): ReactNode { + const captions = useCaptionsOptions(); + if (!captions) return children; + + const { state, options, value } = captions; + const label = + value === CAPTIONS_OFF_VALUE ? 'Off' : (options.find((option) => option.value === value)?.label ?? 'Off'); + + return ( + + {children} + + ); +} + +export function MenuItemSettingProvider({ type, children }: MenuItemSettingProviderProps): ReactNode { + if (type === 'playback-rate') + return {children}; + if (type === 'captions') return {children}; + return children; +} diff --git a/packages/react/src/ui/menu/menu-item-type.ts b/packages/react/src/ui/menu/menu-item-type.ts new file mode 100644 index 00000000..1707a129 --- /dev/null +++ b/packages/react/src/ui/menu/menu-item-type.ts @@ -0,0 +1 @@ +export type MenuItemSettingType = 'playback-rate' | 'captions'; diff --git a/packages/react/src/ui/menu/menu-item-value.tsx b/packages/react/src/ui/menu/menu-item-value.tsx new file mode 100644 index 00000000..a021be37 --- /dev/null +++ b/packages/react/src/ui/menu/menu-item-value.tsx @@ -0,0 +1,35 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext, useOptionalMenuItemSettingContext } from './context'; + +export interface MenuItemValueProps extends UIComponentProps<'span', MenuState> {} + +/** Displays the current value for a settings menu item from `Menu.Item` or `Menu.Trigger` context. */ +export const MenuItemValue = forwardRef(function MenuItemValue( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + const setting = useOptionalMenuItemSettingContext(); + if (!setting) return null; + + return renderElement( + 'span', + { render, className, style }, + { + state, + stateAttrMap, + ref: forwardedRef, + props: [{ 'aria-live': 'off' as const, children: setting.label }, elementProps], + } + ); +}); + +export namespace MenuItemValue { + export type Props = MenuItemValueProps; +} diff --git a/packages/react/src/ui/menu/menu-item.tsx b/packages/react/src/ui/menu/menu-item.tsx index edd4cdf0..5e8a3a3d 100644 --- a/packages/react/src/ui/menu/menu-item.tsx +++ b/packages/react/src/ui/menu/menu-item.tsx @@ -6,18 +6,22 @@ import { forwardRef, useCallback, useEffect, useRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; -import { useMenuContext, useSubMenuContext } from './context'; +import { useMenuContext, useOptionalMenuItemSettingContext, useSubMenuContext } from './context'; +import { MenuItemSettingProvider } from './menu-item-setting-provider'; +import type { MenuItemSettingType } from './menu-item-type'; export interface MenuItemProps extends UIComponentProps<'div', MenuState> { /** Called when the item is selected. */ onSelect?: () => void; /** Whether the item is disabled. */ disabled?: boolean; + /** Setting kind for submenu triggers (`playback-rate` or `captions`). */ + type?: MenuItemSettingType | undefined; } /** A single action in the menu. Renders a `
` with `role="menuitem"`. */ export const MenuItem = forwardRef(function MenuItem( - { render, className, style, onSelect, disabled, onClick, ...elementProps }, + { render, className, style, onSelect, disabled, type, onClick, ...elementProps }, forwardedRef ) { const { menu, state } = useMenuContext(); @@ -47,6 +51,54 @@ export const MenuItem = forwardRef(function MenuI menu.highlight(element, { focus: false }); }, [menu, disabled]); + const item = ( + + ); + + if (!type) return item; + + return {item}; +}); + +interface MenuItemContentProps { + disabled: boolean | undefined; + elementProps: Record; + onClick: (event: React.MouseEvent) => void; + onPointerEnter: () => void; + render: MenuItemProps['render']; + className: MenuItemProps['className']; + style: MenuItemProps['style']; + state: MenuState; + forwardedRef: React.ForwardedRef; + elementRef: React.RefObject; +} + +function MenuItemContent({ + disabled, + elementProps, + onClick, + onPointerEnter, + render, + className, + style, + state, + forwardedRef, + elementRef, +}: MenuItemContentProps) { + const setting = useOptionalMenuItemSettingContext(); + const settingAttrs = setting ? { 'data-availability': setting.availability } : undefined; + return renderElement( 'div', { render, className, style }, @@ -57,14 +109,15 @@ export const MenuItem = forwardRef(function MenuI { role: 'menuitem' as const, 'aria-disabled': disabled ? true : undefined, - onClick: handleClick, - onPointerEnter: handlePointerEnter, + onClick, + onPointerEnter, + ...settingAttrs, }, elementProps, ], } ); -}); +} export namespace MenuItem { export type Props = MenuItemProps; diff --git a/packages/react/src/ui/menu/menu-trigger.tsx b/packages/react/src/ui/menu/menu-trigger.tsx index bf174d73..e70287f4 100644 --- a/packages/react/src/ui/menu/menu-trigger.tsx +++ b/packages/react/src/ui/menu/menu-trigger.tsx @@ -3,16 +3,25 @@ import type { MenuState } from '@videojs/core'; import { isMenuNavigationKey, type UIKeyboardEvent } from '@videojs/core/dom'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; -import { forwardRef, useCallback, useEffect, useRef } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; import { useSafeId } from '../../utils/use-safe-id'; -import { useMenuContext, useSubMenuContext } from './context'; +import { + MenuTriggerChildContextProvider, + useMenuContext, + useOptionalMenuItemSettingContext, + useSubMenuContext, +} from './context'; +import { MenuItemSettingProvider } from './menu-item-setting-provider'; +import type { MenuItemSettingType } from './menu-item-type'; -export interface MenuTriggerProps extends UIComponentProps<'button', MenuState> { - /** Disables the trigger. Only meaningful when used as a submenu trigger inside a parent menu. */ +export interface MenuTriggerProps extends Omit, 'type'> { + /** Disables the trigger. */ disabled?: boolean; + /** Setting kind for submenu triggers (`playback-rate` or `captions`). */ + type?: MenuItemSettingType | undefined; } function toUIKeyboardEvent(event: React.KeyboardEvent): UIKeyboardEvent { @@ -46,7 +55,7 @@ function preventMenuKeyDefault(event: React.KeyboardEvent): void { * that pushes the submenu on click or ArrowRight. */ export const MenuTrigger = forwardRef(function MenuTrigger( - { render, className, style, disabled, onClick, onKeyDown, ...elementProps }, + { render, className, style, disabled, type, onClick, onKeyDown, ...elementProps }, forwardedRef ) { const { core, menu, state, anchorName, contentId } = useMenuContext(); @@ -87,12 +96,13 @@ export const MenuTrigger = forwardRef) => { (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); + if (disabled) return; if (event.key === 'ArrowRight') { event.preventDefault(); openSubMenu(); } }, - [onKeyDown, openSubMenu] + [disabled, onKeyDown, openSubMenu] ); const handlePointerEnter = useCallback(() => { @@ -112,50 +122,133 @@ export const MenuTrigger = forwardRef { + if (!disabled) return menu.triggerProps; + + return { + onClick: (event: React.MouseEvent) => { + event.preventDefault(); + }, + onKeyDown: (event: React.KeyboardEvent) => { + const keyboardEvent = toUIKeyboardEvent(event); + if (event.key === 'Enter' || event.key === ' ' || isMenuNavigationKey(keyboardEvent)) { + event.preventDefault(); + } + }, + }; + }, [disabled, menu.triggerProps]); + // Submenu trigger mode — renders as a div with role="menuitem" if (isSubMenuTrigger) { - return renderElement( - 'div', - { render, className, style }, - { - state: parentState, - ref: [forwardedRef as React.Ref, elementRef as React.Ref], - props: [ - { - id: triggerId, - role: 'menuitem' as const, - 'aria-haspopup': 'menu' as const, - 'aria-expanded': isExpanded, - 'aria-disabled': disabled ? true : undefined, - 'data-has-submenu': '', - onClick: handleSubMenuClick, - onKeyDownCapture: preventMenuKeyDefault, - onKeyDown: handleSubMenuKeyDown, - onPointerEnter: handlePointerEnter, - }, - elementProps, - ], - } + const trigger = ( + ); + + if (!type) return trigger; + + return {trigger}; } - return renderElement( - 'button', - { render, className, style }, - { - state, - ref: [forwardedRef as React.Ref, triggerRef], - props: [ - { type: 'button' as const, ...core.getTriggerAttrs(state, contentId) }, - state.open ? { onKeyDownCapture: preventMenuKeyDefault } : undefined, - menu.triggerProps, - elementProps, - ], - } + const rootTrigger = ( + + {renderElement( + 'button', + { render, className, style }, + { + state, + ref: [forwardedRef as React.Ref, triggerRef], + props: [ + { type: 'button' as const, ...core.getTriggerAttrs(state, contentId) }, + disabled ? { disabled: true, 'aria-disabled': 'true' as const } : undefined, + state.open ? { onKeyDownCapture: preventMenuKeyDefault } : undefined, + rootTriggerProps, + elementProps, + ], + } + )} + ); + + if (!type) return rootTrigger; + + return {rootTrigger}; }); export namespace MenuTrigger { export type Props = MenuTriggerProps; export type State = MenuState; } + +interface MenuTriggerSubmenuProps { + render: MenuTriggerProps['render']; + className: MenuTriggerProps['className']; + style: MenuTriggerProps['style']; + disabled: boolean | undefined; + elementProps: Record; + forwardedRef: React.ForwardedRef; + elementRef: React.RefObject; + triggerId: string; + parentState: MenuState; + isExpanded: boolean; + onSubMenuClick: (event: React.MouseEvent) => void; + onSubMenuKeyDown: (event: React.KeyboardEvent) => void; + onPointerEnter: () => void; +} + +function MenuTriggerSubmenu({ + render, + className, + style, + disabled, + elementProps, + forwardedRef, + elementRef, + triggerId, + parentState, + isExpanded, + onSubMenuClick, + onSubMenuKeyDown, + onPointerEnter, +}: MenuTriggerSubmenuProps) { + const setting = useOptionalMenuItemSettingContext(); + const settingAttrs = setting ? { 'data-availability': setting.availability } : undefined; + + return renderElement( + 'div', + { render, className, style }, + { + state: parentState, + ref: [forwardedRef, elementRef as React.Ref], + props: [ + { + id: triggerId, + role: 'menuitem' as const, + 'aria-haspopup': 'menu' as const, + 'aria-expanded': isExpanded, + 'aria-disabled': disabled ? true : undefined, + 'data-has-submenu': '', + onClick: onSubMenuClick, + onKeyDownCapture: preventMenuKeyDefault, + onKeyDown: onSubMenuKeyDown, + onPointerEnter, + ...settingAttrs, + }, + elementProps, + ], + } + ); +} diff --git a/packages/react/src/ui/menu/tests/menu.test.tsx b/packages/react/src/ui/menu/tests/menu.test.tsx index 16aa0f4d..1e694408 100644 --- a/packages/react/src/ui/menu/tests/menu.test.tsx +++ b/packages/react/src/ui/menu/tests/menu.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import type { KeyboardEventHandler, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPlayerWrapper } from '../../../testing/mocks'; import { ControlsContextProvider } from '../../controls/context'; import { MenuBack } from '../menu-back'; import { MenuCheckboxItem } from '../menu-checkbox-item'; @@ -10,6 +11,7 @@ import { MenuGroup } from '../menu-group'; import { MenuGroupLabel } from '../menu-group-label'; import { MenuItem } from '../menu-item'; import { MenuItemIndicator } from '../menu-item-indicator'; +import { MenuItemValue } from '../menu-item-value'; import { MenuRadioGroup } from '../menu-radio-group'; import { MenuRadioItem } from '../menu-radio-item'; import { MenuRoot } from '../menu-root'; @@ -778,4 +780,42 @@ describe('MenuContent', () => { expect(onRootOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'blur' })); }); }); + + it('forwards disabled to a root trigger render prop and prevents opening', () => { + render( + + } /> + Captions + + ); + + const trigger = screen.getByTestId('trigger'); + expect(trigger).toHaveProperty('disabled', true); + + fireEvent.click(trigger); + + expect(screen.queryByTestId('content')).toBeNull(); + + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }); + trigger.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(screen.queryByTestId('content')).toBeNull(); + }); + + it('provides setting context for root trigger item values', () => { + const { Wrapper } = createPlayerWrapper({ playbackRates: [1, 1.5], playbackRate: 1.5 }); + + render( + + + Speed + + Speed + , + { wrapper: Wrapper } + ); + + expect(screen.getByTestId('value').textContent).toBe('1.5×'); + }); }); diff --git a/packages/react/src/ui/playback-rate-menu/context.tsx b/packages/react/src/ui/playback-rate-menu/context.tsx deleted file mode 100644 index 814f254f..00000000 --- a/packages/react/src/ui/playback-rate-menu/context.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import type { MediaPlaybackRateState, PlaybackRateMenuCore } from '@videojs/core'; -import { createContext, useContext } from 'react'; - -export interface PlaybackRateMenuContextValue { - core: PlaybackRateMenuCore; - media: MediaPlaybackRateState; - state: PlaybackRateMenuCore.State; -} - -const PlaybackRateMenuContext = createContext(null); - -export function PlaybackRateMenuProvider({ - value, - children, -}: { - value: PlaybackRateMenuContextValue; - children: React.ReactNode; -}): React.ReactNode { - return {children}; -} - -export function usePlaybackRateMenuContext(): PlaybackRateMenuContextValue { - const ctx = useContext(PlaybackRateMenuContext); - if (!ctx) throw new Error('PlaybackRateMenu compound components must be used within a PlaybackRateMenu.Root'); - return ctx; -} diff --git a/packages/react/src/ui/playback-rate-menu/index.parts.ts b/packages/react/src/ui/playback-rate-menu/index.parts.ts deleted file mode 100644 index 43437993..00000000 --- a/packages/react/src/ui/playback-rate-menu/index.parts.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { - PlaybackRateMenuContent as Content, - PlaybackRateMenuContent as Popup, - type PlaybackRateMenuContentProps as ContentProps, - type PlaybackRateMenuContentProps as PopupProps, -} from './playback-rate-menu-content'; -export { PlaybackRateMenuRoot as Root, type PlaybackRateMenuRootProps as RootProps } from './playback-rate-menu-root'; -export { - PlaybackRateMenuTrigger as Trigger, - type PlaybackRateMenuTriggerProps as TriggerProps, -} from './playback-rate-menu-trigger'; -export { - type PlaybackRateMenuOption, - type PlaybackRateMenuResult, - usePlaybackRateMenu, -} from './use-playback-rate-menu'; diff --git a/packages/react/src/ui/playback-rate-menu/index.ts b/packages/react/src/ui/playback-rate-menu/index.ts deleted file mode 100644 index de8a889d..00000000 --- a/packages/react/src/ui/playback-rate-menu/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as PlaybackRateMenu from './index.parts'; -export { - type PlaybackRateMenuOption, - type PlaybackRateMenuResult, - usePlaybackRateMenu, -} from './use-playback-rate-menu'; diff --git a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-content.tsx b/packages/react/src/ui/playback-rate-menu/playback-rate-menu-content.tsx deleted file mode 100644 index 31a4a39b..00000000 --- a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-content.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { type PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; -import { forwardRef } from 'react'; - -import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { MenuContent } from '../menu/menu-content'; -import { usePlaybackRateMenuContext } from './context'; - -export interface PlaybackRateMenuContentProps extends UIComponentProps<'div', PlaybackRateMenuCore.State> {} - -export const PlaybackRateMenuContent = forwardRef( - function PlaybackRateMenuContent({ render, className, style, children, ...elementProps }, forwardedRef) { - const { state } = usePlaybackRateMenuContext(); - - return ( - - renderElement( - 'div', - { render, className, style }, - { - state, - stateAttrMap: PlaybackRateMenuDataAttrs, - ref: [forwardedRef], - props: [menuProps, elementProps], - } - ) - } - > - {children} - - ); - } -); - -export namespace PlaybackRateMenuContent { - export type Props = PlaybackRateMenuContentProps; - export type State = PlaybackRateMenuCore.State; -} diff --git a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-root.tsx b/packages/react/src/ui/playback-rate-menu/playback-rate-menu-root.tsx deleted file mode 100644 index be9efd96..00000000 --- a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-root.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import { PlaybackRateMenuCore } from '@videojs/core'; -import { logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; -import type { ReactNode } from 'react'; -import { useState } from 'react'; - -import { usePlayer } from '../../player/context'; -import { MenuRoot, type MenuRootProps } from '../menu/menu-root'; -import { PlaybackRateMenuProvider } from './context'; - -export interface PlaybackRateMenuRootProps extends Omit, PlaybackRateMenuCore.Props { - children?: ReactNode; -} - -export function PlaybackRateMenuRoot({ - label, - formatRate, - disabled, - align = 'center', - children, - ...menuProps -}: PlaybackRateMenuRootProps): ReactNode { - const playbackRate = usePlayer(selectPlaybackRate); - const [core] = useState(() => new PlaybackRateMenuCore()); - - core.setProps({ label, formatRate, disabled }); - - if (!playbackRate) { - if (__DEV__) logMissingFeature('PlaybackRateMenu', selectPlaybackRate.displayName ?? 'playbackRate'); - return null; - } - - core.setMedia(playbackRate); - const state = core.getState(); - - return ( - - - {children} - - - ); -} - -export namespace PlaybackRateMenuRoot { - export type Props = PlaybackRateMenuRootProps; - export type State = PlaybackRateMenuCore.State; -} diff --git a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-trigger.tsx b/packages/react/src/ui/playback-rate-menu/playback-rate-menu-trigger.tsx deleted file mode 100644 index 39edb567..00000000 --- a/packages/react/src/ui/playback-rate-menu/playback-rate-menu-trigger.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import { type PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; -import { isNull, isUndefined } from '@videojs/utils/predicate'; -import type { ReactNode, Ref } from 'react'; -import { forwardRef, isValidElement } from 'react'; - -import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { MenuTrigger } from '../menu/menu-trigger'; -import { usePlaybackRateMenuContext } from './context'; - -export interface PlaybackRateMenuTriggerProps extends UIComponentProps<'button', PlaybackRateMenuCore.State> {} - -function hasChildren(children: ReactNode): boolean { - return !isUndefined(children) && !isNull(children) && children !== false; -} - -export const PlaybackRateMenuTrigger = forwardRef( - function PlaybackRateMenuTrigger({ render, className, style, children, disabled, ...elementProps }, forwardedRef) { - const { core, state } = usePlaybackRateMenuContext(); - const isDisabled = state.disabled || Boolean(disabled); - const renderedChildren = isValidElement<{ children?: ReactNode }>(render) ? render.props.children : undefined; - const hasOwnChildren = !isUndefined(children); - const hasRenderedChildren = !isUndefined(renderedChildren); - const triggerChildren = hasOwnChildren ? children : hasRenderedChildren ? undefined : core.getRateLabel(state.rate); - const childrenProps = !isUndefined(triggerChildren) ? { children: triggerChildren } : undefined; - const inlineLabelProps = - hasChildren(triggerChildren) || hasChildren(renderedChildren) ? { 'data-inline-rate-label': '' } : undefined; - - return ( - { - const isSubmenuTrigger = menuProps.role === 'menuitem'; - const element = isSubmenuTrigger ? 'div' : 'button'; - const disabledProps = isSubmenuTrigger ? undefined : { disabled: isDisabled }; - - return renderElement( - element, - { render, className, style }, - { - state, - stateAttrMap: PlaybackRateMenuDataAttrs, - ref: [forwardedRef as Ref], - props: [menuProps, core.getAttrs(state), elementProps, disabledProps, inlineLabelProps, childrenProps], - } - ); - }} - /> - ); - } -); - -export namespace PlaybackRateMenuTrigger { - export type Props = PlaybackRateMenuTriggerProps; - export type State = PlaybackRateMenuCore.State; -} diff --git a/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts b/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts deleted file mode 100644 index 88302669..00000000 --- a/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import type { PlaybackRateMenuCore } from '@videojs/core'; -import { useCallback } from 'react'; - -import { usePlaybackRateMenuContext } from './context'; - -export interface PlaybackRateMenuOption { - rate: number; - value: string; - label: string; - disabled: boolean; -} - -export interface PlaybackRateMenuResult { - state: PlaybackRateMenuCore.State; - rate: number; - value: string; - options: PlaybackRateMenuOption[]; - disabled: boolean; - setRate: (rate: number) => void; - setValue: (value: string) => void; -} - -export function usePlaybackRateMenu(): PlaybackRateMenuResult { - const { core, media, state } = usePlaybackRateMenuContext(); - - const setRate = useCallback((rate: number) => core.select(media, rate), [core, media]); - const setValue = useCallback((value: string) => core.selectValue(media, value), [core, media]); - - return { - state, - rate: state.rate, - value: core.getRateValue(state.rate), - options: state.rates.map((rate) => ({ - rate, - value: core.getRateValue(rate), - label: core.getRateLabel(rate), - disabled: state.disabled, - })), - disabled: state.disabled, - setRate, - setValue, - }; -} - -export namespace usePlaybackRateMenu { - export type Result = PlaybackRateMenuResult; - export type Option = PlaybackRateMenuOption; -} diff --git a/packages/react/src/ui/playback-rate/index.ts b/packages/react/src/ui/playback-rate/index.ts new file mode 100644 index 00000000..f2f18bbd --- /dev/null +++ b/packages/react/src/ui/playback-rate/index.ts @@ -0,0 +1,6 @@ +export { + type PlaybackRateOption, + type PlaybackRateOptionsProps, + type PlaybackRateOptionsResult, + usePlaybackRateOptions, +} from './use-playback-rate-options'; diff --git a/packages/react/src/ui/playback-rate-menu/tests/playback-rate-menu.test.tsx b/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx similarity index 52% rename from packages/react/src/ui/playback-rate-menu/tests/playback-rate-menu.test.tsx rename to packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx index 78f469db..01c02e00 100644 --- a/packages/react/src/ui/playback-rate-menu/tests/playback-rate-menu.test.tsx +++ b/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx @@ -1,14 +1,17 @@ +'use client'; + import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createPlayerWrapper } from '../../../testing/mocks'; import { Menu } from '../../menu'; -import { PlaybackRateMenu, usePlaybackRateMenu } from '..'; +import { PlaybackRateButton } from '../../playback-rate-button'; +import { usePlaybackRateOptions } from '../use-playback-rate-options'; afterEach(cleanup); -function renderPlaybackRateMenu({ +function renderPlaybackRateOptions({ playbackRates = [0.5, 1, 1.5, 2], playbackRate = 1.5, setPlaybackRate = vi.fn(), @@ -22,20 +25,23 @@ function renderPlaybackRateMenu({ const { Wrapper } = createPlayerWrapper({ playbackRates, playbackRate, setPlaybackRate }); render( - - - - - - , + + + + + + , { wrapper: Wrapper } ); return { setPlaybackRate }; } -function PlaybackRateMenuItems(): ReactNode { - const { options, setValue, value } = usePlaybackRateMenu(); +function PlaybackRateRadioGroup({ formatRate }: { formatRate?: ((rate: number) => string) | undefined }): ReactNode { + const state = usePlaybackRateOptions(formatRate ? { formatRate } : undefined); + if (!state) return null; + + const { options, setValue, value } = state; return ( @@ -48,46 +54,30 @@ function PlaybackRateMenuItems(): ReactNode { ); } -describe('PlaybackRateMenu', () => { - it('renders a dynamic trigger from the current playback rate', () => { - renderPlaybackRateMenu({ playbackRate: 1.5 }); +function PlaybackRateTrigger({ formatRate }: { formatRate?: ((rate: number) => string) | undefined }): ReactNode { + const state = usePlaybackRateOptions(formatRate ? { formatRate } : undefined); + if (!state) return null; + + return ( + } />} + /> + ); +} + +describe('usePlaybackRateOptions', () => { + it('renders a trigger with the current playback rate state', () => { + renderPlaybackRateOptions({ playbackRate: 1.5 }); const trigger = screen.getByTestId('trigger'); - expect(trigger.textContent).toBe('1.5×'); expect(trigger.getAttribute('aria-label')).toBe('Playback rate 1.5'); expect(trigger.getAttribute('data-rate')).toBe('1.5'); - expect(trigger.hasAttribute('data-inline-rate-label')).toBe(true); - }); - - it('renders the current rate inside a rendered button without children', () => { - const { Wrapper } = createPlayerWrapper({ playbackRates: [1, 1.5], playbackRate: 1 }); - - render( - - } /> - , - { wrapper: Wrapper } - ); - - expect(screen.getByTestId('trigger').textContent).toBe('1×'); - }); - - it('preserves children on a rendered button', () => { - const { Wrapper } = createPlayerWrapper({ playbackRates: [1, 1.5], playbackRate: 1 }); - - render( - - Text} /> - , - { wrapper: Wrapper } - ); - - expect(screen.getByText('Text').textContent).toBe('Text'); }); it('renders radio items from the available playback rates', () => { - renderPlaybackRateMenu({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 }); + renderPlaybackRateOptions({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 }); expect(screen.getByRole('menuitemradio', { name: '1×' }).getAttribute('aria-checked')).toBe('false'); expect(screen.getByRole('menuitemradio', { name: '1.25×' }).getAttribute('aria-checked')).toBe('true'); @@ -95,36 +85,34 @@ describe('PlaybackRateMenu', () => { }); it('center aligns the popup by default', () => { - renderPlaybackRateMenu(); + renderPlaybackRateOptions(); expect(screen.getByTestId('content').getAttribute('data-align')).toBe('center'); }); it('sets the selected playback rate', () => { const setPlaybackRate = vi.fn(); - renderPlaybackRateMenu({ setPlaybackRate }); + renderPlaybackRateOptions({ setPlaybackRate }); fireEvent.click(screen.getByRole('menuitemradio', { name: '2×' })); expect(setPlaybackRate).toHaveBeenCalledWith(2); }); - it('uses a custom rate formatter for the trigger and items', () => { - renderPlaybackRateMenu({ + it('uses a custom rate formatter for items', () => { + renderPlaybackRateOptions({ playbackRate: 1, formatRate: (rate) => (rate === 1 ? 'Normal' : `${rate}×`), }); - expect(screen.getByTestId('trigger').textContent).toBe('Normal'); expect(screen.getByRole('menuitemradio', { name: 'Normal' }).getAttribute('aria-checked')).toBe('true'); }); it('disables the trigger when there are no rates', () => { - renderPlaybackRateMenu({ playbackRates: [] }); + renderPlaybackRateOptions({ playbackRates: [] }); const trigger = screen.getByTestId('trigger'); - expect(trigger.hasAttribute('disabled')).toBe(true); - expect(trigger.hasAttribute('data-disabled')).toBe(true); + expect(trigger.getAttribute('aria-disabled')).toBe('true'); }); }); diff --git a/packages/react/src/ui/playback-rate/use-playback-rate-options.ts b/packages/react/src/ui/playback-rate/use-playback-rate-options.ts new file mode 100644 index 00000000..e475c0c6 --- /dev/null +++ b/packages/react/src/ui/playback-rate/use-playback-rate-options.ts @@ -0,0 +1,68 @@ +'use client'; + +import { + type PlaybackRateRadioGroupCore, + PlaybackRateRadioGroupCore as PlaybackRateRadioGroupCoreClass, +} from '@videojs/core'; +import { logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; +import { useCallback, useState } from 'react'; + +import { usePlayer } from '../../player/context'; + +export interface PlaybackRateOptionsProps extends PlaybackRateRadioGroupCore.Props {} + +export interface PlaybackRateOption { + rate: number; + value: string; + label: string; + disabled: boolean; +} + +export interface PlaybackRateOptionsResult { + state: PlaybackRateRadioGroupCore.State; + rate: number; + value: string; + options: PlaybackRateOption[]; + disabled: boolean; + setRate: (rate: number) => void; + setValue: (value: string) => void; +} + +export function usePlaybackRateOptions(props?: PlaybackRateOptionsProps): PlaybackRateOptionsResult | null { + const media = usePlayer(selectPlaybackRate); + const [core] = useState(() => new PlaybackRateRadioGroupCoreClass()); + + core.setProps(props ?? {}); + + const setRate = useCallback((rate: number) => core.select(media!, rate), [core, media]); + const setValue = useCallback((value: string) => core.selectValue(media!, value), [core, media]); + + if (!media) { + if (__DEV__) logMissingFeature('usePlaybackRateOptions', selectPlaybackRate.displayName ?? 'playbackRate'); + return null; + } + + core.setMedia(media); + const state = core.getState(); + + return { + state, + rate: state.rate, + value: core.getRateValue(state.rate), + options: state.rates.map((rate) => ({ + rate, + value: core.getRateValue(rate), + label: core.getRateLabel(rate), + disabled: state.disabled, + })), + disabled: state.disabled, + setRate, + setValue, + }; +} + +export namespace usePlaybackRateOptions { + export type Props = PlaybackRateOptionsProps; + export type Result = PlaybackRateOptionsResult; + export type Option = PlaybackRateOption; +} diff --git a/packages/skins/src/default/css/audio.css b/packages/skins/src/default/css/audio.css index ed854321..ce0f6d65 100644 --- a/packages/skins/src/default/css/audio.css +++ b/packages/skins/src/default/css/audio.css @@ -8,9 +8,9 @@ @import "./components/button.css"; @import "./components/button-group.css"; @import "./components/icons.css"; -@import "./components/menus.css"; @import "./components/slider.css"; @import "./components/popup.css"; +@import "./components/menus.css"; @import "../../shared/css/audio/icon-state.css"; /* ========================================================================== diff --git a/packages/skins/src/default/css/components/button.css b/packages/skins/src/default/css/components/button.css index f9ff9beb..f8837c35 100644 --- a/packages/skins/src/default/css/components/button.css +++ b/packages/skins/src/default/css/components/button.css @@ -125,6 +125,27 @@ } } +/* Settings button */ +.media-default-skin .media-button--settings { + display: none; + + & .media-icon--settings { + transition: transform 150ms ease-in-out; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } + } + + &[aria-expanded="true"] .media-icon--settings { + transform: rotate(90deg); + } +} + +.media-default-skin .media-button-group:has([data-availability="available"]) .media-button--settings { + display: grid; +} + /* Live button — wide pill button with a status dot (gray → red at the live edge) rendered via ::before, and "LIVE" text rendered as the button's own text content. */ diff --git a/packages/skins/src/default/css/components/menus.css b/packages/skins/src/default/css/components/menus.css index 0bee0382..6350d994 100644 --- a/packages/skins/src/default/css/components/menus.css +++ b/packages/skins/src/default/css/components/menus.css @@ -3,93 +3,194 @@ Note: Menus use `.media-popover` styles for positioning and transitions. ========================================================================== */ -.media-default-skin .media-popover.media-menu { +.media-default-skin .media-menu { + --menu-transition-duration: 200ms; + --menu-item-transition-duration: 100ms; box-sizing: border-box; - min-width: min(6rem, var(--media-popover-available-width, 6rem)); + min-width: 6rem; max-width: var(--media-popover-available-width, none); max-height: var(--media-popover-available-height, none); padding: 0.375rem; overflow: auto; overscroll-behavior: none; border-radius: 1.25rem; + transition-timing-function: ease-in-out; + transition-duration: var(--menu-transition-duration); + transition-property: scale, opacity, filter, width, height; - &::before { - display: none; + @media (prefers-reduced-motion: reduce) { + --menu-transition-duration: 0ms; + --menu-item-transition-duration: 0ms; } -} -.media-default-skin .media-popover.media-menu .media-menu__group { - position: relative; - display: flex; - flex-direction: column; - gap: 0.125rem; - - &::before { + & .media-menu__panel { position: absolute; - position-anchor: --media-menu-item-highlight-anchor; - inset: anchor(inside); - pointer-events: none; - content: ""; - background-color: oklch(from currentColor l c h / 0.1); - border-radius: calc(infinity * 1px); - transition: inset ease-in-out 100ms; - } + inset: 0; + padding: 0.375rem; + overflow: auto; + overscroll-behavior: none; + outline: none; + translate: 0 0; + transition-timing-function: ease-in-out; + transition-duration: var(--menu-transition-duration); + transition-property: translate, filter; + will-change: translate; - @supports not (top: anchor(top)) { - &::before { - display: none; + &[data-starting-style], + &[data-ending-style] { + overflow: hidden; + } + + /* Root settings view — slides out when a submenu is active */ + &[data-menu-root-view][data-menu-view-state="inactive"] { + filter: blur(8px); + translate: -100% 0; + } + + /* Submenu panels — slide in/out alongside the root view */ + &[data-submenu] { + z-index: 10; + + &:not([data-open], [data-ending-style]) { + translate: -100% 0; + transition-property: none; + } + + &[data-starting-style], + &[data-ending-style] { + pointer-events: none; + filter: blur(8px); + } + + &[data-starting-style][data-direction="forward"], + &[data-ending-style][data-direction="back"] { + translate: 100% 0; + } + + &[data-ending-style][data-direction="forward"], + &[data-starting-style][data-direction="back"] { + translate: -100% 0; + } } } -} -.media-default-skin .media-popover.media-menu .media-menu__item { - display: flex; - gap: 0.5rem; - align-items: center; - justify-content: space-between; - min-height: 2rem; - padding: 0 0.75rem; - font-variant-numeric: tabular-nums; - color: inherit; - cursor: pointer; - outline: 2px solid transparent; - outline-offset: -2px; - border-radius: calc(infinity * 1px); - - &:hover, - &[data-highlighted] { - anchor-name: --media-menu-item-highlight-anchor; + & .media-menu__group { + display: flex; + flex-direction: column; + gap: 0.125rem; } - @supports not (top: anchor(top)) { + & .media-menu__item, + & .media-menu__back { + display: flex; + align-items: center; + padding: 0.375rem 0.75rem; + text-shadow: 0 1px 0 var(--media-current-shadow-color); + cursor: pointer; + user-select: none; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: calc(infinity * 1px); + transition: + background-color var(--menu-item-transition-duration) ease-out, + color var(--menu-item-transition-duration) ease-out; + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } + &:hover, &[data-highlighted] { background-color: oklch(from currentColor l c h / 0.1); } + + &[data-availability="unavailable"], + &[data-availability="unsupported"] { + display: none; + } + + & .media-menu__chevron:first-child { + margin-left: -0.25rem; + } + & .media-menu__chevron:last-child { + margin-right: -0.25rem; + } } - &:focus-visible { - outline-color: currentColor; - outline-offset: 2px; + & .media-menu__indicator { + flex-shrink: 0; + margin-right: -0.25rem; + opacity: 0; + + & .media-icon { + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); + } } - &[aria-checked="true"] .media-menu__indicator { - opacity: 1; + & .media-menu__item { + gap: 0.5rem; + justify-content: space-between; + font-variant-numeric: tabular-nums; + color: inherit; + + &[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } + + &[aria-checked="true"] .media-menu__indicator { + opacity: 1; + } } - &[aria-disabled="true"] { - pointer-events: none; - cursor: not-allowed; - opacity: 0.5; + & .media-menu__back { + gap: 0.375rem; + width: 100%; + margin-bottom: 0.125rem; + font-weight: 500; + color: oklch(from currentColor l c h / 0.7); + + &:focus-visible { + color: inherit; + } + + &:hover, + &[data-highlighted] { + color: inherit; + } + } + + & .media-menu__hint { + display: inline-flex; + gap: 0.25rem; + align-items: center; + min-width: 0; + margin-left: auto; + font-size: 0.75rem; + color: oklch(from currentColor l c h / 0.65); + } + + & .media-menu__hint-label { + max-width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .media-menu__chevron { + width: 0.875rem; + height: 0.875rem; + } + + /* Settings menu */ + &.media-menu--settings { + --menu-transition-duration: 300ms; + position: relative; + width: var(--media-menu-width); + min-width: 11rem; + height: var(--media-menu-height); + overflow: hidden; } } - -.media-default-skin .media-popover.media-menu .media-menu__indicator { - flex-shrink: 0; - margin-right: -0.25rem; - opacity: 0; -} - -.media-default-skin .media-popover.media-menu .media-menu__indicator .media-icon { - filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); -} diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css index c3d006d4..e21d3ef2 100644 --- a/packages/skins/src/default/css/video.css +++ b/packages/skins/src/default/css/video.css @@ -10,11 +10,11 @@ @import "./components/button.css"; @import "./components/button-group.css"; @import "./components/icons.css"; -@import "./components/menus.css"; @import "./components/poster.css"; @import "./components/thumbnail.css"; @import "./components/slider.css"; @import "./components/popup.css"; +@import "./components/menus.css"; @import "./components/captions.css"; @import "./components/input-feedback.css"; @import "../../shared/css/video/icon-state.css"; diff --git a/packages/skins/src/default/tailwind/components/menu.ts b/packages/skins/src/default/tailwind/components/menu.ts index 5fc61d63..f6f3624a 100644 --- a/packages/skins/src/default/tailwind/components/menu.ts +++ b/packages/skins/src/default/tailwind/components/menu.ts @@ -1,31 +1,87 @@ import { cn } from '@videojs/utils/style'; +import { popup } from './popup'; + +const panelBase = cn( + 'absolute inset-0 overflow-auto overscroll-none p-1.5 outline-none translate-none', + 'data-starting-style:overflow-hidden data-ending-style:overflow-hidden', + 'transition-[translate,filter] duration-(--menu-transition-duration) ease-in-out will-change-[translate]' +); + +const rootView = cn( + panelBase, + 'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur' +); + +const submenuPanel = cn( + panelBase, + 'z-10', + 'not-data-open:not-data-ending-style:-translate-x-full', + 'not-data-open:not-data-ending-style:transition-none', + 'data-starting-style:pointer-events-none data-ending-style:pointer-events-none', + 'data-starting-style:blur data-ending-style:blur', + 'data-starting-style:data-[direction=forward]:translate-x-full', + 'data-ending-style:data-[direction=forward]:-translate-x-full', + 'data-starting-style:data-[direction=back]:-translate-x-full', + 'data-ending-style:data-[direction=back]:translate-x-full' +); + +const itemBase = cn( + 'flex cursor-pointer select-none items-center rounded-full py-1.5 px-3', + 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)', + 'outline-2 -outline-offset-2 outline-transparent', + 'transition-[background-color,color] duration-(--menu-item-transition-duration) ease-out', + 'hover:bg-current/10 data-highlighted:bg-current/10', + 'focus-visible:outline-current focus-visible:outline-offset-2' +); + +const menuTokens = cn( + '[--menu-transition-duration:200ms] [--menu-item-transition-duration:100ms]', + 'motion-reduce:[--menu-transition-duration:0ms] motion-reduce:[--menu-item-transition-duration:0ms]' +); + +const menuHostShell = cn( + popup.popover, + menuTokens, + 'max-w-(--media-popover-available-width,none) max-h-(--media-popover-available-height,none)', + 'transition-[transform,scale,opacity,filter,width,height] duration-(--menu-transition-duration) ease-in-out', + 'box-border rounded-[1.25rem] p-1.5 overscroll-none' +); + export const menu = { - root: cn( - 'box-border min-w-[min(6rem,var(--media-popover-available-width,6rem))]', - 'max-w-(--media-popover-available-width) max-h-(--media-popover-available-height)', - 'p-1.5 !overflow-auto overscroll-none rounded-[1.25rem]', - 'before:hidden' - ), - group: cn( - 'relative flex flex-col gap-0.5', - 'before:hidden supports-[top:anchor(top)]:before:block', - 'before:absolute before:pointer-events-none before:rounded-full before:bg-current/10', - 'before:transition-[inset] before:duration-100 before:ease-in-out', - 'before:[position-anchor:--media-menu-item-highlight-anchor] before:[inset:anchor(inside)]' + /** Standalone menu popover host (audio playback rate, sandbox demos). */ + root: cn(menuHostShell, 'min-w-24 overflow-auto'), + /** Settings menu viewport host with nested submenu navigation. */ + settings: cn( + menuHostShell, + '[--menu-transition-duration:250ms]', + 'relative min-w-44 w-(--media-menu-width) h-(--media-menu-height)', + 'overflow-hidden' ), + group: 'flex flex-col gap-0.5', item: cn( - 'group/menu-item flex min-h-8 cursor-pointer items-center justify-between gap-2 rounded-full px-3', - 'tabular-nums text-inherit outline-2 -outline-offset-2 outline-transparent', - 'hover:bg-current/10 data-highlighted:bg-current/10', - 'supports-[top:anchor(top)]:hover:bg-transparent supports-[top:anchor(top)]:data-highlighted:bg-transparent', - 'supports-[top:anchor(top)]:hover:[anchor-name:--media-menu-item-highlight-anchor]', - 'supports-[top:anchor(top)]:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]', - 'focus-visible:outline-current focus-visible:outline-offset-2', + itemBase, + 'group/menu-item justify-between gap-2 tabular-nums text-inherit', + 'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden', 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' ), indicator: cn( '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', '[&_.media-icon]:drop-shadow-[0_1px_0_var(--media-current-shadow-color)]' ), + /** Root settings view — slides out when a submenu is active. */ + rootView, + /** Submenu panel — slides in/out alongside the root view. */ + submenuPanel, + back: cn( + itemBase, + 'mb-0.5 w-full gap-1.5 font-medium text-current/70', + 'hover:text-inherit data-highlighted:text-inherit focus-visible:text-inherit' + ), + hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65', + hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap', + chevron: 'size-3.5 first:-ml-1 last:-mr-1', + settingsGroup: 'group/settings', + settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid', + settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0', }; diff --git a/packages/skins/src/minimal/css/audio.css b/packages/skins/src/minimal/css/audio.css index e76908fe..1525fdd7 100644 --- a/packages/skins/src/minimal/css/audio.css +++ b/packages/skins/src/minimal/css/audio.css @@ -31,6 +31,9 @@ --media-tooltip-text-color: currentColor; --media-tooltip-side-offset: 0.75rem; --media-tooltip-boundary-offset: 0.75rem; + --media-popover-background-color: oklch(1 0 0 / 0.1); + --media-popover-border-color: oklch(0 0 0 / 0.05); + --media-popover-backdrop-filter: blur(16px) saturate(1.5); --media-popover-side-offset: 0.75rem; --media-popover-boundary-offset: 0.75rem; diff --git a/packages/skins/src/minimal/css/components/button.css b/packages/skins/src/minimal/css/components/button.css index 9e418dfb..ed2cd40b 100644 --- a/packages/skins/src/minimal/css/components/button.css +++ b/packages/skins/src/minimal/css/components/button.css @@ -131,6 +131,27 @@ } } +/* Settings button */ +.media-minimal-skin .media-button--settings { + display: none; + + & .media-icon--settings { + transition: transform 150ms ease-in-out; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } + } + + &[aria-expanded="true"] .media-icon--settings { + transform: rotate(90deg); + } +} + +.media-minimal-skin .media-button-group:has([data-availability="available"]) .media-button--settings { + display: grid; +} + /* Live button — wide pill button with a status dot (gray → red at the live edge) rendered via ::before, and "LIVE" text rendered as the button's own text content. */ diff --git a/packages/skins/src/minimal/css/components/menus.css b/packages/skins/src/minimal/css/components/menus.css index 5fb9b939..c136cfe2 100644 --- a/packages/skins/src/minimal/css/components/menus.css +++ b/packages/skins/src/minimal/css/components/menus.css @@ -3,70 +3,198 @@ Note: Menus use `.media-popover` styles for positioning and transitions. ========================================================================== */ -.media-minimal-skin .media-popover.media-menu { +.media-minimal-skin .media-menu { + --menu-item-height: 1.875rem; + --menu-transition-duration: 200ms; + --menu-item-transition-duration: 100ms; box-sizing: border-box; - min-width: min(6rem, var(--media-popover-available-width, 6rem)); + min-width: 6rem; max-width: var(--media-popover-available-width, none); max-height: var(--media-popover-available-height, none); padding: 0.25rem; overflow: auto; overscroll-behavior: none; - background-color: var(--media-tooltip-background-color); + background-color: var(--media-popover-background-color); border-radius: 0.75rem; box-shadow: - 0 0 0 1px var(--media-tooltip-border-color), + 0 0 0 1px var(--media-popover-border-color), 0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1); - backdrop-filter: var(--media-tooltip-backdrop-filter); + backdrop-filter: var(--media-popover-backdrop-filter); + transition-timing-function: ease-in-out; + transition-duration: var(--menu-transition-duration); + transition-property: scale, opacity, filter, width, height; - &::before { - display: none; - } -} - -.media-minimal-skin .media-popover.media-menu .media-menu__group { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.media-minimal-skin .media-popover.media-menu .media-menu__item { - display: flex; - gap: 0.5rem; - align-items: center; - justify-content: space-between; - min-height: 2rem; - padding: 0 0.625rem; - font-variant-numeric: tabular-nums; - color: inherit; - cursor: pointer; - outline: 2px solid transparent; - outline-offset: -2px; - border-radius: 0.5rem; - - &:hover, - &[data-highlighted] { - background-color: oklch(from currentColor l c h / 0.1); + @media (prefers-reduced-motion: reduce) { + --menu-transition-duration: 0ms; + --menu-item-transition-duration: 0ms; } - &:focus-visible { - outline-color: currentColor; - outline-offset: 2px; + & .media-menu__panel { + position: absolute; + inset: 0; + padding: 0.25rem; + overflow: auto; + overscroll-behavior: none; + outline: none; + translate: 0 0; + transition-timing-function: ease-in-out; + transition-duration: var(--menu-transition-duration); + transition-property: translate, filter; + will-change: translate; + + &[data-starting-style], + &[data-ending-style] { + overflow: hidden; + } + + /* Root settings view — slides out when a submenu is active */ + &[data-menu-root-view] { + &[data-menu-view-state="inactive"] { + filter: blur(8px); + translate: -100% 0; + } + } + + /* Submenu panels — slide in/out alongside the root view */ + &[data-submenu] { + z-index: 10; + + &:not([data-open], [data-ending-style]) { + translate: -100% 0; + transition-property: none; + } + + &[data-starting-style], + &[data-ending-style] { + pointer-events: none; + filter: blur(8px); + } + + &[data-starting-style][data-direction="forward"], + &[data-ending-style][data-direction="back"] { + translate: 100% 0; + } + + &[data-ending-style][data-direction="forward"], + &[data-starting-style][data-direction="back"] { + translate: -100% 0; + } + } } - &[aria-disabled="true"] { - pointer-events: none; - cursor: not-allowed; - opacity: 0.5; + & .media-menu__group { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + & .media-menu__item, + & .media-menu__back { + display: flex; + align-items: center; + height: var(--menu-item-height); + text-shadow: 0 1px 0 var(--media-current-shadow-color); + cursor: pointer; + user-select: none; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: 0.5rem; + transition: + background-color var(--menu-item-transition-duration) ease-out, + color var(--menu-item-transition-duration) ease-out; + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } + + &:hover, + &[data-highlighted] { + background-color: oklch(from currentColor l c h / 0.1); + } + + &[data-availability="unavailable"], + &[data-availability="unsupported"] { + display: none; + } + + & .media-menu__chevron:first-child { + margin-left: -0.25rem; + } + & .media-menu__chevron:last-child { + margin-right: -0.25rem; + } + } + + & .media-menu__indicator { + flex-shrink: 0; + margin-right: -0.25rem; + opacity: 0; + } + + & .media-menu__item { + gap: 0.5rem; + justify-content: space-between; + padding: 0 0.625rem; + font-variant-numeric: tabular-nums; + color: inherit; + + &[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } + + &[aria-checked="true"] .media-menu__indicator { + opacity: 1; + } + } + + & .media-menu__back { + gap: 0.375rem; + width: 100%; + padding: 0 0.625rem; + margin-bottom: 0.125rem; + font-weight: 500; + color: oklch(from currentColor l c h / 0.7); + + &:hover, + &[data-highlighted], + &:focus-visible { + color: inherit; + } + } + + & .media-menu__hint { + display: inline-flex; + gap: 0.25rem; + align-items: center; + min-width: 0; + margin-left: auto; + font-size: 0.75rem; + color: oklch(from currentColor l c h / 0.65); + } + + & .media-menu__hint-label { + max-width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .media-menu__chevron { + width: 0.875rem; + height: 0.875rem; + } + + /* Settings menu */ + &.media-menu--settings { + --menu-transition-duration: 250ms; + position: relative; + width: var(--media-menu-width); + min-width: 11rem; + height: var(--media-menu-height); + overflow: hidden; } } - -.media-minimal-skin .media-popover.media-menu .media-menu__indicator { - flex-shrink: 0; - margin-right: -0.25rem; - opacity: 0; -} - -.media-minimal-skin .media-popover.media-menu .media-menu__item[aria-checked="true"] .media-menu__indicator { - opacity: 1; -} diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index bcae177d..f7e21f90 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -41,6 +41,9 @@ --media-tooltip-text-color: currentColor; --media-tooltip-side-offset: 0.5rem; --media-tooltip-boundary-offset: 0.5rem; + --media-popover-background-color: oklch(1 0 0 / 0.1); + --media-popover-border-color: transparent; + --media-popover-backdrop-filter: blur(16px) saturate(1.5); --media-popover-side-offset: 1.5rem; --media-popover-boundary-offset: 0.5rem; overflow: clip; diff --git a/packages/skins/src/minimal/tailwind/audio.tailwind.ts b/packages/skins/src/minimal/tailwind/audio.tailwind.ts index e197c8f0..63a88db9 100644 --- a/packages/skins/src/minimal/tailwind/audio.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/audio.tailwind.ts @@ -25,6 +25,9 @@ export const root = cn( '[--media-tooltip-text-color:currentColor]', '[--media-tooltip-side-offset:0.75rem]', '[--media-tooltip-boundary-offset:0.75rem]', + '[--media-popover-background-color:oklch(1_0_0/0.1)]', + '[--media-popover-border-color:oklch(0_0_0/0.05)]', + '[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]', '[--media-popover-side-offset:0.75rem]', '[--media-popover-boundary-offset:0.75rem]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', diff --git a/packages/skins/src/minimal/tailwind/components/menu.ts b/packages/skins/src/minimal/tailwind/components/menu.ts index f4fe0cd6..d91c8fc9 100644 --- a/packages/skins/src/minimal/tailwind/components/menu.ts +++ b/packages/skins/src/minimal/tailwind/components/menu.ts @@ -1,21 +1,86 @@ import { cn } from '@videojs/utils/style'; +import { popup } from './popup'; + +const panelBase = cn( + 'absolute inset-0 overflow-auto overscroll-none p-1 outline-none translate-none', + 'data-starting-style:overflow-hidden data-ending-style:overflow-hidden', + 'transition-[translate,filter] duration-(--menu-transition-duration) ease-in-out will-change-[translate]' +); + +const rootView = cn( + panelBase, + 'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur' +); + +const submenuPanel = cn( + panelBase, + 'z-10', + 'not-data-open:not-data-ending-style:-translate-x-full', + 'not-data-open:not-data-ending-style:transition-none', + 'data-starting-style:pointer-events-none data-ending-style:pointer-events-none', + 'data-starting-style:blur data-ending-style:blur', + 'data-starting-style:data-[direction=forward]:translate-x-full', + 'data-ending-style:data-[direction=forward]:-translate-x-full', + 'data-starting-style:data-[direction=back]:-translate-x-full', + 'data-ending-style:data-[direction=back]:translate-x-full' +); + +const itemBase = cn( + 'flex h-(--menu-item-height) cursor-pointer select-none items-center rounded-lg', + 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)', + 'outline-2 -outline-offset-2 outline-transparent', + 'transition-[background-color,color] duration-(--menu-item-transition-duration) ease-out', + 'hover:bg-current/10 data-highlighted:bg-current/10', + 'focus-visible:outline-current focus-visible:outline-offset-2' +); + +const menuTokens = cn( + '[--menu-item-height:1.875rem] [--menu-transition-duration:200ms] [--menu-item-transition-duration:100ms]', + 'motion-reduce:[--menu-transition-duration:0ms] motion-reduce:[--menu-item-transition-duration:0ms]' +); + +const menuHostShell = cn( + popup.popover, + menuTokens, + 'max-w-(--media-popover-available-width,none) max-h-(--media-popover-available-height,none)', + 'bg-(--media-popover-background-color) [backdrop-filter:var(--media-popover-backdrop-filter)]', + 'shadow-[0_0_0_1px_var(--media-popover-border-color),0_4px_6px_-1px_oklch(0_0_0/0.1),0_2px_4px_-2px_oklch(0_0_0/0.1)]', + 'transition-[transform,scale,opacity,filter,width,height] duration-(--menu-transition-duration) ease-in-out', + 'box-border rounded-xl p-1 overscroll-none' +); + export const menu = { - root: cn( - 'box-border min-w-[min(6rem,var(--media-popover-available-width,6rem))]', - 'max-w-(--media-popover-available-width) max-h-(--media-popover-available-height)', - 'bg-(--media-tooltip-background-color) p-1 !overflow-auto overscroll-none rounded-xl', - '[backdrop-filter:var(--media-tooltip-backdrop-filter)]', - 'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/10', - 'before:hidden' + /** Standalone menu popover host (audio playback rate, sandbox demos). */ + root: cn(menuHostShell, 'min-w-24 overflow-auto'), + /** Settings menu viewport host with nested submenu navigation. */ + settings: cn( + menuHostShell, + '[--menu-transition-duration:250ms]', + 'relative min-w-44 w-(--media-menu-width) h-(--media-menu-height)', + 'overflow-hidden' ), group: 'flex flex-col gap-0.5', item: cn( - 'group/menu-item flex min-h-8 cursor-pointer items-center justify-between gap-2 rounded-lg px-2.5', - 'tabular-nums text-inherit outline-2 -outline-offset-2 outline-transparent', - 'hover:bg-current/10 data-highlighted:bg-current/10', - 'focus-visible:outline-current focus-visible:outline-offset-2', + itemBase, + 'group/menu-item justify-between gap-2 px-2.5 tabular-nums text-inherit', + 'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden', 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' ), indicator: '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', + /** Root settings view — slides out when a submenu is active. */ + rootView, + /** Submenu panel — slides in/out alongside the root view. */ + submenuPanel, + back: cn( + itemBase, + 'mb-0.5 w-full gap-1.5 px-2.5 font-medium text-current/70', + 'hover:text-inherit data-highlighted:text-inherit focus-visible:text-inherit' + ), + hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65', + hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap', + chevron: 'size-3.5 first:-ml-1 last:-mr-1', + settingsGroup: 'group/settings', + settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid', + settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0', }; diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index 00b7fd10..59923537 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -41,6 +41,9 @@ export const root = (isShadowDOM: boolean) => '[--media-tooltip-text-color:currentColor]', '[--media-tooltip-side-offset:0.5rem]', '[--media-tooltip-boundary-offset:0.5rem]', + '[--media-popover-background-color:oklch(1_0_0/0.1)]', + '[--media-popover-border-color:transparent]', + '[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]', '[--media-popover-side-offset:1.5rem]', '[--media-popover-boundary-offset:0.5rem]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]',