Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/core/src/core/media/predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { isFunction, isObject } from '@videojs/utils/predicate';

import type {
MediaBufferCapability,
MediaErrorCapability,
MediaFullscreenCapability,
MediaPauseCapability,
MediaPictureInPictureCapability,
MediaPlaybackRateCapability,
MediaRemotePlaybackCapability,
MediaSeekCapability,
MediaSourceCapability,
MediaTextTrackCapability,
MediaVolumeCapability,
} from './types';

export function isMediaPauseCapable(value: unknown): value is MediaPauseCapability {
return isObject(value) && 'paused' in value && 'ended' in value && 'pause' in value && isFunction(value.pause);
}

export function isMediaSeekCapable(value: unknown): value is MediaSeekCapability {
return isObject(value) && 'currentTime' in value && 'duration' in value && 'seeking' in value;
}

export function isMediaSourceCapable(value: unknown): value is MediaSourceCapability {
return (
isObject(value) &&
'src' in value &&
'currentSrc' in value &&
'readyState' in value &&
'load' in value &&
isFunction(value.load)
);
}

export function isMediaVolumeCapable(value: unknown): value is MediaVolumeCapability {
return isObject(value) && 'volume' in value && 'muted' in value;
}

export function isMediaPlaybackRateCapable(value: unknown): value is MediaPlaybackRateCapability {
return isObject(value) && 'playbackRate' in value;
}

export function isMediaBufferCapable(value: unknown): value is MediaBufferCapability {
return isObject(value) && 'buffered' in value && 'seekable' in value;
}

export function isMediaErrorCapable(value: unknown): value is MediaErrorCapability {
return isObject(value) && 'error' in value;
}

export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrackCapability {
return isObject(value) && 'textTracks' in value;
}

export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
return isObject(value) && 'remote' in value && isObject(value.remote);
}

export function isMediaFullscreenCapable(value: unknown): value is MediaFullscreenCapability {
return isObject(value) && 'requestFullscreen' in value && isFunction(value.requestFullscreen);
}

export function isMediaPictureInPictureCapable(value: unknown): value is MediaPictureInPictureCapability {
return isObject(value) && 'requestPictureInPicture' in value && isFunction(value.requestPictureInPicture);
}
Comment thread
mihar-22 marked this conversation as resolved.
4 changes: 2 additions & 2 deletions packages/core/src/core/media/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,13 @@ export interface MediaPictureInPictureState {
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture
*/
requestPictureInPicture(): Promise<void>;
requestPictureInPicture(): Promise<unknown>;
/**
* Exit picture-in-picture mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
*/
exitPictureInPicture(): Promise<void>;
/** Toggle picture-in-picture mode. */
togglePictureInPicture(): Promise<void>;
togglePictureInPicture(): Promise<unknown>;
}
11 changes: 11 additions & 0 deletions packages/core/src/core/media/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ export interface MediaPictureInPictureCapability {
requestPictureInPicture(): Promise<unknown>;
}

export interface RemotePlaybackLike extends EventTarget {
readonly state: string;
prompt(): Promise<void>;
watchAvailability(callback: (available: boolean) => void): Promise<number>;
cancelWatchAvailability(id?: number): Promise<void>;
}

export interface MediaRemotePlaybackCapability {
readonly remote: RemotePlaybackLike;
}

interface MediaEvents extends MediaPlaybackEvents {}

export interface Media extends MediaPlaybackCapability, EventTargetLike<MediaEvents> {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/core/ui/cast-button/cast-button-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ export interface CastButtonProps {
}

export interface CastButtonState extends ButtonState {
/** Current cast connection state. */
castState: CastState;
/** Whether casting can be requested on this platform. */
availability: MediaFeatureAvailability;
/** Whether casting is available (`availability === 'available'`). */
available: boolean;
}

export class CastButtonCore {
Expand All @@ -28,6 +32,7 @@ export class CastButtonCore {
readonly state = createState<CastButtonState>({
castState: 'disconnected',
availability: 'unavailable',
available: false,
label: '',
});

Expand Down Expand Up @@ -70,7 +75,8 @@ export class CastButtonCore {

getState(): CastButtonState {
const media = this.#media!;
this.state.patch({ castState: media.castState, availability: media.castAvailability });
const availability = media.castAvailability;
this.state.patch({ castState: media.castState, availability, available: availability === 'available' });
this.state.patch({ label: this.getLabel(this.state.current) });

return this.state.current;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { StateAttrMap } from '../types';
import type { CastButtonState } from './cast-button-core';

export const CastButtonDataAttrs = {
/** Indicates cast connection state (`disconnected`, `connecting`, or `connected`). */
castState: 'data-cast-state',
/** Indicates cast availability (`available`, `unavailable`, or `unsupported`). */
availability: 'data-availability',
/** Present when casting is available. */
available: 'data-available',
} as const satisfies StateAttrMap<CastButtonState>;
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function createState(overrides: Partial<CastButtonState> = {}): CastButtonState
return {
castState: 'disconnected',
availability: 'available',
available: true,
label: '',
...overrides,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface FullscreenButtonProps {
export interface FullscreenButtonState extends Pick<MediaFullscreenState, 'fullscreen'>, ButtonState {
/** Whether fullscreen can be requested on this platform. */
availability: MediaFullscreenState['fullscreenAvailability'];
/** Whether fullscreen is available (`availability === 'available'`). */
available: boolean;
}

export class FullscreenButtonCore {
Expand All @@ -27,6 +29,7 @@ export class FullscreenButtonCore {
readonly state = createState<FullscreenButtonState>({
fullscreen: false,
availability: 'available',
available: true,
label: '',
});

Expand Down Expand Up @@ -67,24 +70,23 @@ export class FullscreenButtonCore {

getState(): FullscreenButtonState {
const media = this.#media!;
this.state.patch({ fullscreen: media.fullscreen, availability: media.fullscreenAvailability });
const availability = media.fullscreenAvailability;
this.state.patch({ fullscreen: media.fullscreen, availability, available: availability === 'available' });
this.state.patch({ label: this.getLabel(this.state.current) });

return this.state.current;
}

async toggle(media: MediaFullscreenState): Promise<void> {
toggle(media: MediaFullscreenState): void | Promise<void> {
if (this.#props.disabled) return;
if (media.fullscreenAvailability !== 'available') return;

try {
if (media.fullscreen) {
await media.exitFullscreen();
} else {
await media.requestFullscreen();
}
} catch {
// Fullscreen requests can fail (user gesture required, permissions, etc.)
// Call synchronously to preserve the user activation token (iOS Safari
// requires fullscreen requests within the same event handler tick).
if (media.fullscreen) {
return media.exitFullscreen();
} else {
return media.requestFullscreen();
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ export const FullscreenButtonDataAttrs = {
fullscreen: 'data-fullscreen',
/** Indicates fullscreen availability (`available` or `unsupported`). */
availability: 'data-availability',
/** Present when fullscreen is available. */
available: 'data-available',
} as const satisfies StateAttrMap<FullscreenButtonState>;
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function createState(overrides: Partial<FullscreenButtonState> = {}): Fullscreen
return {
fullscreen: false,
availability: 'available',
available: true,
label: '',
...overrides,
};
Expand Down Expand Up @@ -84,42 +85,42 @@ describe('FullscreenButtonCore', () => {
});

describe('toggle', () => {
it('calls requestFullscreen when not fullscreen', async () => {
it('calls requestFullscreen when not fullscreen', () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreen: false });
await core.toggle(media);
core.toggle(media);
expect(media.requestFullscreen).toHaveBeenCalled();
});

it('calls exitFullscreen when fullscreen', async () => {
it('calls exitFullscreen when fullscreen', () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreen: true });
await core.toggle(media);
core.toggle(media);
expect(media.exitFullscreen).toHaveBeenCalled();
});

it('does nothing when disabled', async () => {
it('does nothing when disabled', () => {
const core = new FullscreenButtonCore({ disabled: true });
const media = createMediaState();
await core.toggle(media);
core.toggle(media);
expect(media.requestFullscreen).not.toHaveBeenCalled();
});

it('does nothing when unsupported', async () => {
it('does nothing when unsupported', () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreenAvailability: 'unsupported' });
await core.toggle(media);
core.toggle(media);
expect(media.requestFullscreen).not.toHaveBeenCalled();
});

it('catches fullscreen errors silently', async () => {
it('catches fullscreen errors silently', () => {
const core = new FullscreenButtonCore();
const media = createMediaState({
requestFullscreen: vi.fn(async () => {
throw new Error('permission denied');
}),
});
await expect(core.toggle(media)).resolves.toBeUndefined();
expect(() => core.toggle(media)).not.toThrow();
Comment thread
mihar-22 marked this conversation as resolved.
Outdated
});
});
});
6 changes: 5 additions & 1 deletion packages/core/src/core/ui/pip-button/pip-button-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface PiPButtonProps {
export interface PiPButtonState extends Pick<MediaPictureInPictureState, 'pip'>, ButtonState {
/** Whether picture-in-picture can be requested on this platform. */
availability: MediaPictureInPictureState['pipAvailability'];
/** Whether picture-in-picture is available (`availability === 'available'`). */
available: boolean;
}

export class PiPButtonCore {
Expand All @@ -27,6 +29,7 @@ export class PiPButtonCore {
readonly state = createState<PiPButtonState>({
pip: false,
availability: 'available',
available: true,
label: '',
});

Expand Down Expand Up @@ -67,7 +70,8 @@ export class PiPButtonCore {

getState(): PiPButtonState {
const media = this.#media!;
this.state.patch({ pip: media.pip, availability: media.pipAvailability });
const availability = media.pipAvailability;
this.state.patch({ pip: media.pip, availability, available: availability === 'available' });
this.state.patch({ label: this.getLabel(this.state.current) });

return this.state.current;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/core/ui/pip-button/pip-button-data-attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ export const PiPButtonDataAttrs = {
pip: 'data-pip',
/** Indicates picture-in-picture availability (`available` or `unsupported`). */
availability: 'data-availability',
/** Present when picture-in-picture is available. */
available: 'data-available',
} as const satisfies StateAttrMap<PiPButtonState>;
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function createState(overrides: Partial<PiPButtonState> = {}): PiPButtonState {
return {
pip: false,
availability: 'available',
available: true,
label: '',
...overrides,
};
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/dom/media/audio-host.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Audio, AudioEvents } from '../../core/media/types';
import { HTMLMediaElementHost } from './media-host';

export class HTMLAudioElementHost extends HTMLMediaElementHost<HTMLAudioElement, AudioEvents> implements Audio {}
export const AUDIO_ELEMENT_HOST_SYMBOL = Symbol.for('@videojs/audio-element-host');

export class HTMLAudioElementHost extends HTMLMediaElementHost<HTMLAudioElement, AudioEvents> implements Audio {
readonly [AUDIO_ELEMENT_HOST_SYMBOL] = true;
}
2 changes: 1 addition & 1 deletion packages/core/src/dom/media/castable/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MixinReturn } from '@videojs/utils/types';
import type { RemotePlaybackLike } from '../predicate';
import type { RemotePlaybackLike } from '../../../core/media/types';
import { GoogleCastProvider } from './google-cast-provider';
import { RemotePlayback } from './remote-playback';
import type { CastableMediaProps, CastableMediaSuperclass } from './types';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/dom/media/castable/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RemotePlaybackLike } from '../predicate';
import type { RemotePlaybackLike } from '../../../core/media/types';
import type { RemotePlayback } from './remote-playback';
import type { CastOptions } from './utils';

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/dom/media/dash/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import * as dashjs from 'dashjs';
import type { MediaEngineHost } from '../../../core/media/types';
import { HTMLVideoElementHost } from '../video-host';

export const DASH_MEDIA_SYMBOL = Symbol.for('@videojs/dash-media');

export class DashMedia
extends HTMLVideoElementHost
implements MediaEngineHost<dashjs.MediaPlayerClass, HTMLVideoElement>
{
readonly [DASH_MEDIA_SYMBOL] = true;

#engine: dashjs.MediaPlayerClass;
#src = '';

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/dom/media/hls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export const SourceTypes = {
MP4: 'video/mp4',
};

export const HLS_MEDIA_SYMBOL = Symbol.for('@videojs/hls-media');

export class HlsMedia extends HTMLVideoElementHost {
readonly [HLS_MEDIA_SYMBOL] = true;

#delegate: HlsJsMedia | NativeHlsMedia | null = null;
#src = '';
#type: SourceType | undefined;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/dom/media/media-host.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ErrorLike, EventLike, EventTargetLike } from '../../core/media/types';

export const MEDIA_ELEMENT_HOST_SYMBOL = Symbol.for('@videojs/media-element-host');

const EMPTY_TIME_RANGES: Readonly<TimeRanges> = Object.freeze({
length: 0,
start() {
Expand All @@ -8,12 +10,14 @@ const EMPTY_TIME_RANGES: Readonly<TimeRanges> = Object.freeze({
end() {
return 0;
},
} as TimeRanges);
});

export class HTMLMediaElementHost<T extends HTMLMediaElement, Events extends { [K in keyof Events]: EventLike }>
extends EventTarget
implements EventTargetLike<Events>
{
readonly [MEDIA_ELEMENT_HOST_SYMBOL] = true;

#target: T | null = null;
#types = new Set<string>();

Expand Down
Loading
Loading