diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 2cc2181b..443c3eab 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -33,6 +33,8 @@ export interface MediaPlaybackState { * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause */ pause(): void; + /** Toggle play/pause. Returns `true` if playback started. */ + togglePaused(): boolean; } /** Indicates whether a feature can be programmatically controlled on this platform. */ @@ -161,6 +163,8 @@ export interface MediaFullscreenState { * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitFullscreen */ exitFullscreen(): Promise; + /** Toggle fullscreen mode. */ + toggleFullscreen(): Promise; } export interface MediaControlsState { @@ -296,4 +300,6 @@ export interface MediaPictureInPictureState { * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture */ exitPictureInPicture(): Promise; + /** Toggle picture-in-picture mode. */ + togglePictureInPicture(): Promise; } diff --git a/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts b/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts index cd6daf13..5bdf65ca 100644 --- a/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts +++ b/packages/core/src/core/ui/buffering-indicator/tests/buffering-indicator-core.test.ts @@ -12,6 +12,7 @@ function createMediaState(overrides: Partial = {}): MediaPla waiting: false, play: vi.fn(async () => {}), pause: vi.fn(), + togglePaused: vi.fn(() => true), ...overrides, }; } diff --git a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts index a3fcdf1c..8cc51c43 100644 --- a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts +++ b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts @@ -10,6 +10,7 @@ function createMediaState(overrides: Partial = {}): MediaF fullscreenAvailability: 'available', requestFullscreen: vi.fn(async () => {}), exitFullscreen: vi.fn(async () => {}), + toggleFullscreen: vi.fn(async () => {}), ...overrides, }; } diff --git a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts index 082aac7f..5abddf09 100644 --- a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts +++ b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts @@ -10,6 +10,7 @@ function createMediaState(overrides: Partial = {}): pipAvailability: 'available', requestPictureInPicture: vi.fn(async () => {}), exitPictureInPicture: vi.fn(async () => {}), + togglePictureInPicture: vi.fn(async () => {}), ...overrides, }; } diff --git a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts index b998fe1c..47e15959 100644 --- a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts +++ b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts @@ -12,6 +12,7 @@ function createMediaState(overrides: Partial = {}): MediaPla waiting: false, play: vi.fn(async () => {}), pause: vi.fn(), + togglePaused: vi.fn(() => true), ...overrides, }; } diff --git a/packages/core/src/core/ui/poster/tests/poster-core.test.ts b/packages/core/src/core/ui/poster/tests/poster-core.test.ts index b46d74da..5f3231f3 100644 --- a/packages/core/src/core/ui/poster/tests/poster-core.test.ts +++ b/packages/core/src/core/ui/poster/tests/poster-core.test.ts @@ -11,6 +11,7 @@ function createMediaState(overrides: Partial = {}): MediaPla waiting: false, play: vi.fn(async () => {}), pause: vi.fn(), + togglePaused: vi.fn(() => true), ...overrides, }; } diff --git a/packages/core/src/dom/gesture/actions.ts b/packages/core/src/dom/gesture/actions.ts new file mode 100644 index 00000000..7a2811e4 --- /dev/null +++ b/packages/core/src/dom/gesture/actions.ts @@ -0,0 +1,71 @@ +import { isFunction, isUndefined } from '@videojs/utils/predicate'; + +import type { AnyPlayerStore } from '../media/types'; +import { selectPlaybackRate, selectTime, selectVolume } from '../store/selectors'; + +export type GestureActionName = + | 'togglePaused' + | 'toggleMuted' + | 'toggleFullscreen' + | 'toggleSubtitles' + | 'togglePictureInPicture' + | 'toggleControls' + | 'seekStep' + | 'volumeStep' + | 'speedUp' + | 'speedDown'; + +export interface GestureActionContext { + store: AnyPlayerStore; + value?: number | undefined; + event: PointerEvent; +} + +export type GestureActionResolver = (context: GestureActionContext) => void; + +/** Actions that need custom logic beyond `store.state[action]()`. */ +const GESTURE_ACTION_OVERRIDES: Partial> = { + seekStep({ store, value }) { + if (isUndefined(value)) return; + const time = selectTime(store.state); + if (!time) return; + time.seek(time.currentTime + value); + }, + + volumeStep({ store, value }) { + if (isUndefined(value)) return; + const vol = selectVolume(store.state); + if (!vol) return; + vol.setVolume(vol.volume + value); + }, + + speedUp({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1; + rate.setPlaybackRate(playbackRates[next]!); + }, + + speedDown({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx <= 0 ? playbackRates.length - 1 : idx - 1; + rate.setPlaybackRate(playbackRates[next]!); + }, +}; + +export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined { + const override = GESTURE_ACTION_OVERRIDES[name as GestureActionName]; + if (override) return override; + + // Direct store method call — togglePaused, toggleMuted, toggleFullscreen, etc. + return ({ store }) => { + const method = (store.state as Record)[name]; + if (isFunction(method)) method(); + else if (__DEV__) console.warn(`[vjs-gesture] Unknown action: "${name}"`); + }; +} diff --git a/packages/core/src/dom/gesture/tests/actions.test.ts b/packages/core/src/dom/gesture/tests/actions.test.ts new file mode 100644 index 00000000..b5b97087 --- /dev/null +++ b/packages/core/src/dom/gesture/tests/actions.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GestureActionContext } from '../actions'; +import { resolveGestureAction } from '../actions'; + +describe('resolveGestureAction', () => { + it('returns a resolver for override actions', () => { + expect(resolveGestureAction('seekStep')).toBeTypeOf('function'); + expect(resolveGestureAction('volumeStep')).toBeTypeOf('function'); + expect(resolveGestureAction('speedUp')).toBeTypeOf('function'); + expect(resolveGestureAction('speedDown')).toBeTypeOf('function'); + }); + + it('returns a resolver for direct store actions', () => { + expect(resolveGestureAction('togglePaused')).toBeTypeOf('function'); + expect(resolveGestureAction('toggleMuted')).toBeTypeOf('function'); + expect(resolveGestureAction('toggleFullscreen')).toBeTypeOf('function'); + expect(resolveGestureAction('toggleSubtitles')).toBeTypeOf('function'); + expect(resolveGestureAction('togglePictureInPicture')).toBeTypeOf('function'); + expect(resolveGestureAction('toggleControls')).toBeTypeOf('function'); + }); + + it('always returns a resolver (warns for unknown in __DEV__)', () => { + const resolver = resolveGestureAction('nonexistent'); + expect(resolver).toBeTypeOf('function'); + + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolver!(ctx({})); + expect(spy).toHaveBeenCalledWith('[vjs-gesture] Unknown action: "nonexistent"'); + spy.mockRestore(); + }); +}); + +describe('direct store actions', () => { + it('calls togglePaused on store state', () => { + const togglePaused = vi.fn(); + resolveGestureAction('togglePaused')!(ctx({ togglePaused })); + expect(togglePaused).toHaveBeenCalledOnce(); + }); + + it('calls toggleMuted on store state', () => { + const toggleMuted = vi.fn(); + resolveGestureAction('toggleMuted')!(ctx({ toggleMuted })); + expect(toggleMuted).toHaveBeenCalledOnce(); + }); + + it('calls toggleFullscreen on store state', () => { + const toggleFullscreen = vi.fn(); + resolveGestureAction('toggleFullscreen')!(ctx({ toggleFullscreen })); + expect(toggleFullscreen).toHaveBeenCalledOnce(); + }); + + it('calls toggleControls on store state', () => { + const toggleControls = vi.fn(); + resolveGestureAction('toggleControls')!(ctx({ toggleControls })); + expect(toggleControls).toHaveBeenCalledOnce(); + }); + + it('calls toggleSubtitles on store state', () => { + const toggleSubtitles = vi.fn(); + resolveGestureAction('toggleSubtitles')!(ctx({ toggleSubtitles })); + expect(toggleSubtitles).toHaveBeenCalledOnce(); + }); + + it('calls togglePictureInPicture on store state', () => { + const togglePictureInPicture = vi.fn(); + resolveGestureAction('togglePictureInPicture')!(ctx({ togglePictureInPicture })); + expect(togglePictureInPicture).toHaveBeenCalledOnce(); + }); +}); + +describe('seekStep', () => { + it('seeks by value offset', () => { + const seek = vi.fn(); + resolveGestureAction('seekStep')!(ctx({ currentTime: 10, duration: 60, seeking: false, seek }, 5)); + expect(seek).toHaveBeenCalledWith(15); + }); + + it('does nothing without value', () => { + const seek = vi.fn(); + resolveGestureAction('seekStep')!(ctx({ currentTime: 10, duration: 60, seeking: false, seek })); + expect(seek).not.toHaveBeenCalled(); + }); +}); + +describe('volumeStep', () => { + it('adjusts volume by value offset', () => { + const setVolume = vi.fn(); + resolveGestureAction('volumeStep')!( + ctx({ volume: 0.5, muted: false, volumeAvailability: 'available', setVolume, toggleMuted: vi.fn() }, 0.1) + ); + expect(setVolume).toHaveBeenCalledWith(0.6); + }); +}); + +describe('speedUp', () => { + it('cycles to next playback rate', () => { + const setPlaybackRate = vi.fn(); + resolveGestureAction('speedUp')!(ctx({ playbackRates: [0.5, 1, 1.5, 2], playbackRate: 1, setPlaybackRate })); + expect(setPlaybackRate).toHaveBeenCalledWith(1.5); + }); + + it('wraps to first rate at end', () => { + const setPlaybackRate = vi.fn(); + resolveGestureAction('speedUp')!(ctx({ playbackRates: [0.5, 1, 2], playbackRate: 2, setPlaybackRate })); + expect(setPlaybackRate).toHaveBeenCalledWith(0.5); + }); +}); + +describe('speedDown', () => { + it('cycles to previous playback rate', () => { + const setPlaybackRate = vi.fn(); + resolveGestureAction('speedDown')!(ctx({ playbackRates: [0.5, 1, 1.5, 2], playbackRate: 1.5, setPlaybackRate })); + expect(setPlaybackRate).toHaveBeenCalledWith(1); + }); + + it('wraps to last rate at beginning', () => { + const setPlaybackRate = vi.fn(); + resolveGestureAction('speedDown')!(ctx({ playbackRates: [0.5, 1, 2], playbackRate: 0.5, setPlaybackRate })); + expect(setPlaybackRate).toHaveBeenCalledWith(2); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function ctx(stateProps: Record, value?: number): GestureActionContext { + return { + store: { state: stateProps } as unknown as GestureActionContext['store'], + value, + event: new Event('pointerup') as PointerEvent, + }; +} diff --git a/packages/core/src/dom/hotkey/actions.ts b/packages/core/src/dom/hotkey/actions.ts index 1b612c72..bc43a2a8 100644 --- a/packages/core/src/dom/hotkey/actions.ts +++ b/packages/core/src/dom/hotkey/actions.ts @@ -16,7 +16,7 @@ export type HotkeyActionName = | 'toggleMuted' | 'toggleFullscreen' | 'toggleSubtitles' - | 'togglePiP' + | 'togglePictureInPicture' | 'seekStep' | 'volumeStep' | 'speedUp' @@ -57,7 +57,7 @@ const HOTKEY_ACTIONS: Record = { selectTextTrack(store.state)?.toggleSubtitles(); }, - togglePiP({ store }) { + togglePictureInPicture({ store }) { const pip = selectPiP(store.state); if (!pip) return; pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture(); diff --git a/packages/core/src/dom/hotkey/tests/actions.test.ts b/packages/core/src/dom/hotkey/tests/actions.test.ts index 58ec99c4..9226ca0f 100644 --- a/packages/core/src/dom/hotkey/tests/actions.test.ts +++ b/packages/core/src/dom/hotkey/tests/actions.test.ts @@ -36,7 +36,7 @@ describe('isHotkeyToggleAction', () => { expect(isHotkeyToggleAction('toggleMuted')).toBe(true); expect(isHotkeyToggleAction('toggleFullscreen')).toBe(true); expect(isHotkeyToggleAction('toggleSubtitles')).toBe(true); - expect(isHotkeyToggleAction('togglePiP')).toBe(true); + expect(isHotkeyToggleAction('togglePictureInPicture')).toBe(true); }); it('returns false for non-toggle actions', () => { diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 227b9c84..deb627c0 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -1,4 +1,5 @@ export * from './feature'; +export * from './gesture/actions'; export * from './gesture/coordinator'; export * from './gesture/create-tap-gesture'; export * from './gesture/gesture'; diff --git a/packages/core/src/dom/store/features/fullscreen.ts b/packages/core/src/dom/store/features/fullscreen.ts index 29e8f108..4b249032 100644 --- a/packages/core/src/dom/store/features/fullscreen.ts +++ b/packages/core/src/dom/store/features/fullscreen.ts @@ -32,6 +32,20 @@ export const fullscreenFeature = definePlayerFeature({ const { media } = target(); return exitFullscreen(media); }, + + async toggleFullscreen() { + const { media, container } = target(); + + if (isFullscreenElement(container, media)) { + return exitFullscreen(media); + } + + if (isPictureInPictureElement(media)) { + await exitPictureInPicture(media); + } + + return requestFullscreen(container, media); + }, }), attach({ target, signal, set }) { diff --git a/packages/core/src/dom/store/features/pip.ts b/packages/core/src/dom/store/features/pip.ts index 14782a44..e988620b 100644 --- a/packages/core/src/dom/store/features/pip.ts +++ b/packages/core/src/dom/store/features/pip.ts @@ -32,6 +32,20 @@ export const pipFeature = definePlayerFeature({ const { media } = target(); return exitPictureInPicture(media); }, + + async togglePictureInPicture() { + const { media, container } = target(); + + if (isPictureInPictureElement(media)) { + return exitPictureInPicture(media); + } + + if (isFullscreenElement(container, media)) { + await exitFullscreen(); + } + + return requestPictureInPicture(media); + }, }), attach({ target, signal, set }) { diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index 41bd3553..111de8ae 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -16,6 +16,15 @@ export const playbackFeature = definePlayerFeature({ pause() { target().media.pause(); }, + togglePaused() { + const media = target().media; + if (media.paused) { + media.play(); + return true; + } + media.pause(); + return false; + }, }), attach({ target, signal, set }) { diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 96d643f0..a34298bd 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -137,7 +137,7 @@ function getTemplateHTML() { - + diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 12d7bbd9..2ec719d3 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -28,6 +28,7 @@ export { ControlsElement } from './ui/controls/controls-element'; export { ControlsGroupElement } from './ui/controls/controls-group-element'; export { ErrorDialogElement } from './ui/error-dialog/error-dialog-element'; export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element'; +export { GestureElement } from './ui/gesture/gesture-element'; export { AriaKeyShortcutsController } from './ui/hotkey/aria-key-shortcuts-controller'; export { HotkeyElement } from './ui/hotkey/hotkey-element'; export { MediaButtonElement } from './ui/media-button-element'; diff --git a/packages/html/src/ui/gesture/gesture-element.ts b/packages/html/src/ui/gesture/gesture-element.ts new file mode 100644 index 00000000..bfe9297c --- /dev/null +++ b/packages/html/src/ui/gesture/gesture-element.ts @@ -0,0 +1,95 @@ +import { + createDoubleTapGesture, + createTapGesture, + type GestureActionName, + type GesturePointerType, + type GestureRegion, + resolveGestureAction, +} from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { containerContext, playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +export class GestureElement extends MediaElement { + static readonly tagName = 'media-gesture'; + + static override properties: PropertyDeclarationMap = { + type: { type: String }, + action: { type: String }, + value: { type: Number }, + pointer: { type: String }, + region: { type: String }, + disabled: { type: Boolean }, + }; + + type: 'tap' | 'doubletap' | (string & {}) = ''; + action: GestureActionName | (string & {}) = ''; + value: number | undefined = undefined; + pointer: GesturePointerType | undefined = undefined; + region: GestureRegion | undefined = undefined; + disabled = false; + + readonly #player = new PlayerController(this, playerContext); + readonly #container = new ContextConsumer(this, { + context: containerContext, + callback: () => this.requestUpdate(), + subscribe: true, + }); + #cleanup: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.style.display = 'none'; + this.#register(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#unregister(); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + // Re-register when attributes change. + if (this.isConnected) { + this.#unregister(); + this.#register(); + } + } + + #register(): void { + const store = this.#player.value; + const container = this.#container.value?.container; + if (!this.type || !this.action || !store || !container) return; + + const resolver = resolveGestureAction(this.action); + if (!resolver) return; + + const { value } = this; + + const onActivate = (event: PointerEvent) => { + resolver({ store, value, event }); + }; + + const options = { + pointer: this.pointer, + region: this.region, + disabled: this.disabled, + }; + + if (this.type === 'doubletap') { + this.#cleanup = createDoubleTapGesture(container, onActivate, options); + } else { + this.#cleanup = createTapGesture(container, onActivate, options); + } + } + + #unregister(): void { + this.#cleanup?.(); + this.#cleanup = null; + } +} diff --git a/packages/html/src/ui/gesture/tests/gesture-element.test.ts b/packages/html/src/ui/gesture/tests/gesture-element.test.ts new file mode 100644 index 00000000..ae8a06cb --- /dev/null +++ b/packages/html/src/ui/gesture/tests/gesture-element.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { GestureElement } from '../gesture-element'; + +beforeAll(() => { + customElements.define('media-gesture', GestureElement); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('GestureElement', () => { + it('has the correct tag name', () => { + expect(GestureElement.tagName).toBe('media-gesture'); + }); + + it('declares expected properties', () => { + const props = GestureElement.properties; + expect(props).toHaveProperty('type'); + expect(props).toHaveProperty('action'); + expect(props).toHaveProperty('value'); + expect(props).toHaveProperty('pointer'); + expect(props).toHaveProperty('region'); + expect(props).toHaveProperty('disabled'); + }); + + it('initializes with default property values', () => { + const el = document.createElement('media-gesture') as GestureElement; + expect(el.type).toBe(''); + expect(el.action).toBe(''); + expect(el.value).toBeUndefined(); + expect(el.pointer).toBeUndefined(); + expect(el.region).toBeUndefined(); + expect(el.disabled).toBe(false); + }); + + it('is hidden when connected', () => { + const el = document.createElement('media-gesture') as GestureElement; + document.body.appendChild(el); + expect(el.style.display).toBe('none'); + }); +}); diff --git a/packages/html/src/ui/pip-button/pip-button-element.ts b/packages/html/src/ui/pip-button/pip-button-element.ts index 866a7027..94edd483 100644 --- a/packages/html/src/ui/pip-button/pip-button-element.ts +++ b/packages/html/src/ui/pip-button/pip-button-element.ts @@ -11,7 +11,7 @@ export class PiPButtonElement extends MediaButtonElement { protected readonly core = new PiPButtonCore(); protected readonly stateAttrMap = PiPButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectPiP); - protected override readonly hotkeyAction = 'togglePiP'; + protected override readonly hotkeyAction = 'togglePictureInPicture'; protected activate(state: MediaPictureInPictureState): void { this.core.toggle(state); diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index e02b978c..ffd6fc9d 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -237,7 +237,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { - + diff --git a/packages/react/src/ui/pip-button/pip-button.tsx b/packages/react/src/ui/pip-button/pip-button.tsx index aff33109..0cd556ba 100644 --- a/packages/react/src/ui/pip-button/pip-button.tsx +++ b/packages/react/src/ui/pip-button/pip-button.tsx @@ -15,7 +15,7 @@ export const PiPButton = createMediaButton({ stateAttrMap: PiPButtonDataAttrs, selector: selectPiP, action: (core, state) => core.toggle(state), - hotkeyAction: 'togglePiP', + hotkeyAction: 'togglePictureInPicture', }); export namespace PiPButton {