Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions packages/core/src/core/ui/mute-button/mute-button-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface MuteButtonProps {
}

export interface MuteButtonState extends Pick<MediaVolumeState, 'muted'> {
availability: MediaVolumeState['volumeAvailability'];
/**
* Derived volume level:
* - `off`: muted or volume is 0
Expand Down Expand Up @@ -68,6 +69,7 @@ export class MuteButtonCore {
getState(): MuteButtonState {
const media = this.#media!;
return {
availability: media.volumeAvailability,
muted: media.muted || media.volume === 0,
volumeLevel: getVolumeLevel(media),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { StateAttrMap } from '../types';
import type { MuteButtonState } from './mute-button-core';

export const MuteButtonDataAttrs = {
/** Indicates whether volume control is available. */
availability: 'data-availability',
/** Present when the media is muted. */
muted: 'data-muted',
/** Indicates the volume level. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function createMediaState(overrides: Partial<MediaVolumeState> = {}): MediaVolum

function createState(overrides: Partial<MuteButtonState> = {}): MuteButtonState {
return {
availability: 'available',
muted: false,
volumeLevel: 'high',
...overrides,
Expand All @@ -31,6 +32,7 @@ describe('MuteButtonCore', () => {
core.setMedia(media);
const state = core.getState();

expect(state.availability).toBe('available');
expect(state.muted).toBe(false);
expect(state.volumeLevel).toBe('high');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('VolumeSliderCore', () => {
expect(state.value).toBe(75);
expect(state.fillPercent).toBe(75);
expect(state.volume).toBe(0.75);
expect(state.availability).toBe('available');
});

it('returns 0 when volume is 0', () => {
Expand Down Expand Up @@ -115,6 +116,15 @@ describe('VolumeSliderCore', () => {
expect(state.muted).toBe(true);
expect(state.fillPercent).toBe(0);
});

it('projects unsupported volume availability', () => {
const core = new VolumeSliderCore();
core.setInput(createInput());
core.setMedia(createMediaState({ volumeAvailability: 'unsupported' }));
const state = core.getState();

expect(state.availability).toBe('unsupported');
});
});

describe('getAttrs', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export interface VolumeSliderProps extends SliderProps {
max?: number | undefined;
}

export interface VolumeSliderState extends SliderState, Pick<MediaVolumeState, 'volume' | 'muted'> {}
export interface VolumeSliderState extends SliderState, Pick<MediaVolumeState, 'volume' | 'muted'> {
/** Whether volume can be programmatically set on this platform. */
availability: MediaVolumeState['volumeAvailability'];
}

/** Volume-domain slider: maps media volume/mute state to slider state. */
export class VolumeSliderCore extends SliderCore {
Expand Down Expand Up @@ -51,6 +54,7 @@ export class VolumeSliderCore extends SliderCore {
fillPercent: effectivelyMuted ? 0 : base.fillPercent,
volume,
muted: effectivelyMuted,
availability: media.volumeAvailability,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ import type { VolumeSliderState } from './volume-slider-core';

export const VolumeSliderDataAttrs = {
...SliderDataAttrs,
/** Indicates volume availability (`available`, `unavailable`, or `unsupported`). */
availability: 'data-availability',
} as const satisfies StateAttrMap<VolumeSliderState>;
51 changes: 48 additions & 3 deletions packages/core/src/dom/store/features/tests/volume.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { volumeFeature } from '../volume';

async function flushProbe(): Promise<void> {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await Promise.resolve();
}

afterEach(() => {
vi.restoreAllMocks();
});

describe('volumeFeature', () => {
describe('attach', () => {
it('syncs volume state on attach', () => {
Expand All @@ -19,15 +30,49 @@ describe('volumeFeature', () => {
expect(store.state.muted).toBe(false);
});

it('sets volumeAvailability on attach', () => {
it('starts unavailable, then resolves volumeAvailability on attach', async () => {
const video = createMockVideo({});
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });

// Should be 'available' or 'unsupported' based on browser capability
expect(store.state.volumeAvailability).toBe('unavailable');

await flushProbe();

expect(['available', 'unsupported']).toContain(store.state.volumeAvailability);
});

it('reports unsupported volume availability when the probe value does not stick', async () => {
const originalCreateElement = document.createElement.bind(document);

vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options);

if (tagName !== 'video') return element;

let volume = 1;
Object.defineProperty(element, 'volume', {
configurable: true,
get() {
return volume;
},
set(_value: number) {
volume = 1;
},
});

return element;
});

const video = createMockVideo({});
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });

await flushProbe();

expect(store.state.volumeAvailability).toBe('unsupported');
});

it('updates on volumechange event', () => {
const video = createMockVideo({ volume: 1, muted: false });

Expand Down
37 changes: 30 additions & 7 deletions packages/core/src/dom/store/features/volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export const volumeFeature = definePlayerFeature({
attach({ target, signal, set }) {
const { media } = target;

set({ volumeAvailability: canSetVolume() });
detectVolumeAvailability().then((volumeAvailability) => {
if (signal.aborted) return;
set({ volumeAvailability });
});

const sync = () => set({ volume: media.volume, muted: media.muted });
sync();
Expand All @@ -54,13 +57,33 @@ export const volumeFeature = definePlayerFeature({
},
});

/** Check if volume can be programmatically set (fails on iOS Safari). */
function canSetVolume(): MediaFeatureAvailability {
function detectVolumeAvailability(): Promise<MediaFeatureAvailability> {
return probeVolumeAvailability().catch(() => 'unsupported');
}

async function probeVolumeAvailability(): Promise<MediaFeatureAvailability> {
const video = document.createElement('video');
const parent = document.body ?? document.documentElement;
const initialVolume = video.volume;
const nextVolume = initialVolume === 0.5 ? 0.25 : 0.5;

video.muted = true;
video.preload = 'none';
video.playsInline = true;
video.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none;';
parent?.append(video);

try {
video.volume = 0.5;
return video.volume === 0.5 ? 'available' : 'unsupported';
} catch {
return 'unsupported';
video.volume = nextVolume;
await waitForProbeFrame();
return video.volume === nextVolume ? 'available' : 'unsupported';
} finally {
video.remove();
}
}

function waitForProbeFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
}
106 changes: 103 additions & 3 deletions packages/html/src/ui/popover/popover-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ export class PopoverElement extends MediaElement {
#disconnect: AbortController | null = null;
#triggerAbort: AbortController | null = null;
#currentTrigger: HTMLElement | null = null;
#triggerObserver: MutationObserver | null = null;
#positionAbort: AbortController | null = null;
#positionFrame = 0;
#resizeObserver: ResizeObserver | null = null;
#positionTrigger: HTMLElement | null = null;
#availabilityHidden = false;

override connectedCallback(): void {
super.connectedCallback();
Expand All @@ -82,6 +84,8 @@ export class PopoverElement extends MediaElement {
// Apply popup event handlers (pointerenter/leave, focusout) to self.
applyElementProps(this, this.#popover.popupProps, { signal: this.#disconnect.signal });

this.#observeTriggerLinkage();

// Subscribe to interaction state for reactive updates.
// Reuse the controller across connect/disconnect cycles to avoid
// leaking stale controllers in the host's controller set.
Expand All @@ -105,12 +109,14 @@ export class PopoverElement extends MediaElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#cleanupPositioning();
this.#cleanupTriggerObserver();
this.#disconnect?.abort();
this.#disconnect = null;
}

override destroyCallback(): void {
this.#cleanupPositioning();
this.#cleanupTriggerObserver();
this.#cleanupTrigger();
this.#popover?.destroy();
super.destroyCallback();
Expand All @@ -137,9 +143,7 @@ export class PopoverElement extends MediaElement {
super.update(_changed);
if (!this.#popover) return;

// Discover trigger via commandfor linkage.
const triggerEl = this.#findTrigger();
this.#syncTrigger(triggerEl);
const availableTriggerEl = this.#syncTriggerLinkage();

// Derive state from core + input.
const input = this.#popover.input.current;
Expand All @@ -152,6 +156,12 @@ export class PopoverElement extends MediaElement {

// Show/hide via Popover API AFTER data attributes are applied so
// `data-starting-style` is present before the first visible frame.
if (!availableTriggerEl) {
tryHidePopover(this);
this.#cleanupPositioning();
return;
}

if (state.open) {
tryShowPopover(this);
} else {
Expand Down Expand Up @@ -196,6 +206,13 @@ export class PopoverElement extends MediaElement {
return root.querySelector<HTMLElement>(`[commandfor="${this.id}"]`);
}

#isTriggerAvailable(triggerEl: HTMLElement | null): triggerEl is HTMLElement {
if (!triggerEl) return false;

const availability = triggerEl.getAttribute('data-availability');
return !availability || availability === 'available';
}

#syncTrigger(triggerEl: HTMLElement | null): void {
if (triggerEl === this.#currentTrigger) return;

Expand All @@ -210,6 +227,89 @@ export class PopoverElement extends MediaElement {
}
}

#observeTriggerLinkage(): void {
this.#cleanupTriggerObserver();

const root = this.getRootNode();
const target = root instanceof Document ? root.documentElement : root;

if (!(target instanceof Node)) return;

this.#triggerObserver = new MutationObserver((records) => {
if (records.some((record) => this.#isTriggerLinkageMutation(record))) {
this.#syncTriggerLinkage();
this.requestUpdate();
}
});

this.#triggerObserver.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['commandfor', 'data-availability'],
});
}

#cleanupTriggerObserver(): void {
this.#triggerObserver?.disconnect();
this.#triggerObserver = null;
}

#isTriggerLinkageMutation(record: MutationRecord): boolean {
if (record.type === 'attributes') {
return record.target instanceof HTMLElement && this.#isLinkedTrigger(record.target);
}

for (const node of record.addedNodes) {
if (node instanceof HTMLElement && this.#isLinkedTrigger(node)) return true;
}

for (const node of record.removedNodes) {
if (node instanceof HTMLElement && this.#isLinkedTrigger(node)) return true;
}

return false;
}

#isLinkedTrigger(element: HTMLElement): boolean {
return !!this.id && element.getAttribute('commandfor') === this.id;
}

#syncTriggerLinkage(): HTMLElement | null {
const triggerEl = this.#findTrigger();
const availableTriggerEl = this.#isTriggerAvailable(triggerEl) ? triggerEl : null;

this.#syncAvailabilityVisibility(Boolean(availableTriggerEl));
this.#syncTrigger(availableTriggerEl);

if (!availableTriggerEl && this.#popover?.input.current.active) {
this.#popover.close();
}

if (!availableTriggerEl) {
tryHidePopover(this);
this.#cleanupPositioning();
}

return availableTriggerEl;
}

#syncAvailabilityVisibility(available: boolean): void {
if (!available) {
if (!this.hidden) {
this.hidden = true;
this.#availabilityHidden = true;
}

return;
}

if (this.#availabilityHidden) {
this.hidden = false;
this.#availabilityHidden = false;
Comment on lines +366 to +378
}
}

#cleanupTrigger(): void {
if (this.#currentTrigger) {
// Remove ARIA attributes and anchor-name style from the old trigger.
Expand Down
Loading
Loading