Skip to content
Open
177 changes: 173 additions & 4 deletions packages/core/src/core/ui/input-feedback/status-announcer-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { createState } from '@videojs/store';
import type { IndicatorCoreProps } from './indicator-lifecycle';
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
import {
DEFAULT_INPUT_INDICATOR_LABELS,
DEFAULT_STATUS_ANNOUNCER_LABELS,
deriveAnnouncerLabel,
formatPlaybackRateAnnouncerLabel,
formatSeekAnnouncerLabel,
formatVolumeValue,
type InputActionEvent,
type InputIndicatorLabels,
type MediaSnapshot,
type StatusAnnouncerLabels,
} from './status';

const ANNOUNCEMENT_DEBOUNCE = 200;

export interface StatusAnnouncerProps extends IndicatorCoreProps {
labels?: Partial<InputIndicatorLabels> | undefined;
labels?: Partial<StatusAnnouncerLabels> | undefined;
shouldAnnounceSeek?: ((snapshot: MediaSnapshot) => boolean) | undefined;
shouldAnnounceVolume?: ((snapshot: MediaSnapshot) => boolean) | undefined;
}

export interface StatusAnnouncerState {
Expand All @@ -22,6 +29,11 @@ export class StatusAnnouncerCore {
readonly state = createState<StatusAnnouncerState>({ label: null });

#props: StatusAnnouncerProps = {};
#snapshot: MediaSnapshot | null = null;
#seekStartTime: number | null = null;
#seekTargetTime: number | null = null;
#seekTimer: ReturnType<typeof setTimeout> | null = null;
#volumeTimer: ReturnType<typeof setTimeout> | null = null;
#close = new IndicatorCloseController(
() => this.state.patch({ label: null }),
() => getIndicatorCloseDelay(this.#props)
Expand All @@ -31,24 +43,181 @@ export class StatusAnnouncerCore {
this.#props = props;
}

resetSnapshot(): void {
this.#snapshot = null;
this.#seekStartTime = null;
this.#seekTargetTime = null;
this.#clearSeekTimer();
this.#clearVolumeTimer();
this.#close.close();
}

destroy(): void {
this.#clearSeekTimer();
this.#clearVolumeTimer();
this.#close.destroy();
}

processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
const label = deriveAnnouncerLabel(event, snapshot, {
...DEFAULT_INPUT_INDICATOR_LABELS,
...DEFAULT_STATUS_ANNOUNCER_LABELS,
...this.#props.labels,
});
if (!label) return false;

this.#announce(label);
return true;
}

processSnapshot(snapshot: MediaSnapshot): boolean {
const previous = this.#snapshot;
this.#snapshot = snapshot;

if (!previous) return false;

const labels = this.#getLabels();
let handled = false;
const queue: string[] = [];

if (hasChanged(previous.paused, snapshot.paused)) {
queue.push(snapshot.paused ? labels.paused : labels.playing);
}

if (hasChanged(previous.subtitlesShowing, snapshot.subtitlesShowing) && snapshot.subtitlesAvailable !== false) {
queue.push(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff);
}

if (hasChanged(previous.fullscreen, snapshot.fullscreen)) {
queue.push(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen);
}

if (hasChanged(previous.pip, snapshot.pip)) {
queue.push(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture);
}

if (hasChanged(previous.playbackRate, snapshot.playbackRate)) {
queue.push(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels));
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (queue.length > 0) {
handled = this.#announce(queue.join('. '));
}

if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) {
handled = true;
}

if (this.#processVolumeSnapshot(previous, snapshot, labels, handled)) {
handled = true;
}
Comment thread
cursor[bot] marked this conversation as resolved.

return handled;
}

#getLabels(): StatusAnnouncerLabels {
return {
...DEFAULT_STATUS_ANNOUNCER_LABELS,
...this.#props.labels,
};
}

#announce(label: string): boolean {
Comment thread
cursor[bot] marked this conversation as resolved.
this.#clearSeekTimer();
this.#clearVolumeTimer();
this.state.patch({ label });
this.#close.arm();
return true;
}

#processVolumeSnapshot(
previous: MediaSnapshot,
snapshot: MediaSnapshot,
labels: StatusAnnouncerLabels,
alreadyHandled: boolean
): boolean {
if (!hasChanged(previous.volume, snapshot.volume) && !hasChanged(previous.muted, snapshot.muted)) return false;
if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return false;
if (alreadyHandled) return false;

const volume = snapshot.volume ?? previous.volume;
const muted = snapshot.muted ?? previous.muted;

if (volume === undefined && muted === undefined) return false;

const label = muted || (volume ?? 0) <= 0 ? labels.muted : `${labels.volume} ${formatVolumeValue(volume ?? 0)}`;
this.#scheduleVolumeAnnouncement(label, snapshot);
return true;
}

#processSeekSnapshot(
previous: MediaSnapshot,
snapshot: MediaSnapshot,
labels: StatusAnnouncerLabels,
alreadyHandled: boolean
): boolean {
if (previous.seeking !== true && snapshot.seeking === true) {
this.#seekStartTime = previous.currentTime ?? null;
this.#seekTargetTime = snapshot.currentTime ?? null;
this.#clearSeekTimer();
return false;
}

if (snapshot.seeking === true) {
this.#seekTargetTime = snapshot.currentTime ?? this.#seekTargetTime;
return false;
}

if (previous.seeking !== true || snapshot.seeking !== false) return false;

const targetTime = snapshot.currentTime ?? this.#seekTargetTime;
const startTime = this.#seekStartTime;
this.#seekStartTime = null;
this.#seekTargetTime = null;

if (targetTime === undefined || targetTime === null || Object.is(targetTime, startTime)) return false;
if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return false;
if (alreadyHandled) return false;

this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels), snapshot);
return true;
}

#scheduleVolumeAnnouncement(label: string, snapshot: MediaSnapshot): void {
this.#clearVolumeTimer();
this.#volumeTimer = setTimeout(() => {
this.#volumeTimer = null;
if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return;
this.#announce(label);
}, ANNOUNCEMENT_DEBOUNCE);
}

#scheduleSeekAnnouncement(label: string, snapshot: MediaSnapshot): void {
this.#clearSeekTimer();
this.#seekTimer = setTimeout(() => {
this.#seekTimer = null;
if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return;
this.#announce(label);
}, ANNOUNCEMENT_DEBOUNCE);
}
Comment thread
cursor[bot] marked this conversation as resolved.

#clearSeekTimer(): void {
if (!this.#seekTimer) return;
clearTimeout(this.#seekTimer);
this.#seekTimer = null;
}

#clearVolumeTimer(): void {
if (!this.#volumeTimer) return;
clearTimeout(this.#volumeTimer);
this.#volumeTimer = null;
}
}

export namespace StatusAnnouncerCore {
export type Props = StatusAnnouncerProps;
export type State = StatusAnnouncerState;
}

function hasChanged<Value>(previous: Value | undefined, next: Value | undefined): next is Value {
return previous !== undefined && next !== undefined && !Object.is(previous, next);
}
27 changes: 26 additions & 1 deletion packages/core/src/core/ui/input-feedback/status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { clamp } from '@videojs/utils/number';
import { formatTime } from '@videojs/utils/time';
import { formatTime, formatTimeAsPhrase } from '@videojs/utils/time';

export type InputActionSource = 'gesture' | 'hotkey';

Expand Down Expand Up @@ -44,13 +44,15 @@ export interface MediaSnapshot {
paused?: boolean | undefined;
volume?: number | undefined;
muted?: boolean | undefined;
playbackRate?: number | undefined;
fullscreen?: boolean | undefined;
subtitlesShowing?: boolean | undefined;
/** When false, caption toggles are unavailable and status feedback is suppressed. */
subtitlesAvailable?: boolean | undefined;
pip?: boolean | undefined;
currentTime?: number | undefined;
duration?: number | undefined;
seeking?: boolean | undefined;
}

export interface InputIndicatorLabels {
Expand All @@ -66,6 +68,11 @@ export interface InputIndicatorLabels {
exitPictureInPicture: string;
}

export interface StatusAnnouncerLabels extends InputIndicatorLabels {
seekedTo: string;
playbackRate: string;
}

export interface StatusDetails {
status: IndicatorStatus;
label: string;
Expand All @@ -86,6 +93,12 @@ export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = {
exitPictureInPicture: 'Exit picture in picture',
};

export const DEFAULT_STATUS_ANNOUNCER_LABELS: StatusAnnouncerLabels = {
...DEFAULT_INPUT_INDICATOR_LABELS,
seekedTo: 'Seeked to',
playbackRate: 'Playback rate',
};

export function isVolumeIndicatorAction(action: string | null | undefined): action is 'toggleMuted' | 'volumeStep' {
return action === 'toggleMuted' || action === 'volumeStep';
}
Expand Down Expand Up @@ -169,10 +182,22 @@ export function formatVolumeValue(volume: number): string {
return `${Math.round(clamp(volume, 0, 1) * 100)}%`;
}

export function formatPlaybackRateValue(rate: number): string {
return `${rate}x`;
}

export function formatCurrentTime(snapshot: MediaSnapshot): string {
return formatTime(snapshot.currentTime ?? 0, snapshot.duration);
}

export function formatSeekAnnouncerLabel(time: number, labels: StatusAnnouncerLabels): string {
return `${labels.seekedTo} ${formatTimeAsPhrase(time)}`;
}

export function formatPlaybackRateAnnouncerLabel(rate: number, labels: StatusAnnouncerLabels): string {
return `${labels.playbackRate} ${formatPlaybackRateValue(rate)}`;
}

export function getStatusIndicatorDisplayValue(state: { value: string | null; label: string | null }): string {
return state.value ?? state.label ?? '';
}
Expand Down
Loading
Loading