mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add <media-gesture> element (#1305)
This commit is contained in:
@@ -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<void>;
|
||||
/** Toggle fullscreen mode. */
|
||||
toggleFullscreen(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MediaControlsState {
|
||||
@@ -296,4 +300,6 @@ export interface MediaPictureInPictureState {
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
|
||||
*/
|
||||
exitPictureInPicture(): Promise<void>;
|
||||
/** Toggle picture-in-picture mode. */
|
||||
togglePictureInPicture(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
|
||||
waiting: false,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(),
|
||||
togglePaused: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ function createMediaState(overrides: Partial<MediaFullscreenState> = {}): MediaF
|
||||
fullscreenAvailability: 'available',
|
||||
requestFullscreen: vi.fn(async () => {}),
|
||||
exitFullscreen: vi.fn(async () => {}),
|
||||
toggleFullscreen: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ function createMediaState(overrides: Partial<MediaPictureInPictureState> = {}):
|
||||
pipAvailability: 'available',
|
||||
requestPictureInPicture: vi.fn(async () => {}),
|
||||
exitPictureInPicture: vi.fn(async () => {}),
|
||||
togglePictureInPicture: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
|
||||
waiting: false,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(),
|
||||
togglePaused: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
|
||||
waiting: false,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(),
|
||||
togglePaused: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Record<GestureActionName, GestureActionResolver>> = {
|
||||
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<string, unknown>)[name];
|
||||
if (isFunction(method)) method();
|
||||
else if (__DEV__) console.warn(`[vjs-gesture] Unknown action: "${name}"`);
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>, value?: number): GestureActionContext {
|
||||
return {
|
||||
store: { state: stateProps } as unknown as GestureActionContext['store'],
|
||||
value,
|
||||
event: new Event('pointerup') as PointerEvent,
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type HotkeyActionName =
|
||||
| 'toggleMuted'
|
||||
| 'toggleFullscreen'
|
||||
| 'toggleSubtitles'
|
||||
| 'togglePiP'
|
||||
| 'togglePictureInPicture'
|
||||
| 'seekStep'
|
||||
| 'volumeStep'
|
||||
| 'speedUp'
|
||||
@@ -57,7 +57,7 @@ const HOTKEY_ACTIONS: Record<HotkeyActionName, HotkeyActionResolver> = {
|
||||
selectTextTrack(store.state)?.toggleSubtitles();
|
||||
},
|
||||
|
||||
togglePiP({ store }) {
|
||||
togglePictureInPicture({ store }) {
|
||||
const pip = selectPiP(store.state);
|
||||
if (!pip) return;
|
||||
pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user