Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions packages/react/src/components/layouts/default/ui/sliders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,14 @@ function DefaultTimeSlider() {
const [instance, setInstance] = React.useState<TimeSliderInstance | null>(null),
[width, setWidth] = React.useState(0),
$src = useMediaState('currentSrc'),
{ thumbnails, sliderChaptersMinWidth, disableTimeSlider, seekStep, noScrubGesture } =
useDefaultLayoutContext(),
{
thumbnails,
sliderChaptersMinWidth,
disableTimeSlider,
seekStep,
noScrubGesture,
translations,
} = useDefaultLayoutContext(),
label = useDefaultLayoutWord('Seek'),
$RemotionSliderThumbnail = useSignal(RemotionSliderThumbnail);

Expand All @@ -97,6 +103,7 @@ function DefaultTimeSlider() {
disabled={disableTimeSlider}
noSwipeGesture={noScrubGesture}
keyStep={seekStep}
translations={translations}
ref={setInstance}
>
<TimeSlider.Chapters
Expand Down
4 changes: 2 additions & 2 deletions packages/vidstack/src/components/aria/announcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Component, effect, peek, State, tick } from 'maverick.js';
import { isString, setAttribute, type DOMEvent } from 'maverick.js/std';

import { useMediaContext, type MediaContext } from '../../core/api/media-context';
import { setAttributeIfEmpty } from '../../utils/dom';
import { getLang, setAttributeIfEmpty } from '../../utils/dom';
import { formatSpokenTime } from '../../utils/time';

/**
Expand Down Expand Up @@ -105,7 +105,7 @@ export class MediaAnnouncer extends Component<

if (seconds >= 1) {
const isForward = newTime >= this.#startedSeekingAt,
spokenTime = formatSpokenTime(seconds);
spokenTime = formatSpokenTime(seconds, getLang(this.el));

this.#setLabel(
`${this.#translate(isForward ? 'Seek Forward' : 'Seek Backward')} ${spokenTime}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ export type DefaultLayoutWord =
| 'Google Cast'
| 'Keyboard Animations'
| 'LIVE'
| 'live'
| 'Loop'
| 'Mute'
| 'Normal'
| 'Off'
| 'out of'
| 'Pause'
| 'Play'
| 'Playback'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { VTTCue } from 'media-captions';
import { useMediaContext, type MediaContext } from '../../../../core/api/media-context';
import type { TextTrack } from '../../../../core/tracks/text/text-track';
import { isCueActive, watchActiveTextTrack } from '../../../../core/tracks/text/utils';
import { requestScopedAnimationFrame } from '../../../../utils/dom';
import { getLang, requestScopedAnimationFrame } from '../../../../utils/dom';
import { round } from '../../../../utils/number';
import { formatSpokenTime, formatTime } from '../../../../utils/time';
import type { ThumbnailSrc } from '../../thumbnails/thumbnail-loader';
Expand Down Expand Up @@ -91,6 +91,7 @@ export class ChaptersRadioGroup extends Component<
startTime: formatTime(Math.max(0, cue.startTime - startTime)),
duration: formatSpokenTime(
Math.min(endTime, cue.endTime) - Math.max(startTime, cue.startTime),
getLang(this.el),
),
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ export class SliderEventsController extends ViewController<
}
}

/**
* A horizontal slider in a right-to-left context runs from right (min) to left (max).
*/
#isRTL() {
return (
this.$props.orientation() !== 'vertical' && getComputedStyle(this.el!).direction === 'rtl'
);
}

#getPointerValue(event: PointerEvent) {
let thumbPositionRate: number,
rect = this.el!.getBoundingClientRect(),
Expand All @@ -208,16 +217,18 @@ export class SliderEventsController extends ViewController<
} else {
if (this.#touch && isNumber(this.#touchStartValue) && this.#provider) {
const { width } = this.#provider.getBoundingClientRect(),
rate = (event.clientX - this.#touch.clientX) / width,
rate = ((event.clientX - this.#touch.clientX) / width) * (this.#isRTL() ? -1 : 1),
range = max() - min(),
diff = range * Math.abs(rate);
thumbPositionRate =
(rate < 0 ? this.#touchStartValue - diff : this.#touchStartValue + diff) / range;
} else {
if (this.#touch && isNumber(this.#touchStartValue)) this.#resetTouchState();

const { left: trackLeft, width: trackWidth } = rect;
thumbPositionRate = (event.clientX - trackLeft) / trackWidth;
const { left: trackLeft, right: trackRight, width: trackWidth } = rect;
thumbPositionRate = this.#isRTL()
? (trackRight - event.clientX) / trackWidth
: (event.clientX - trackLeft) / trackWidth;
}
}

Expand Down Expand Up @@ -357,7 +368,9 @@ export class SliderEventsController extends ViewController<
keyStep = this.#delegate.getKeyStep();

const modifiedStep = !shiftKey ? keyStep : keyStep * shiftKeyMultiplier(),
direction = Number(SliderKeyDirection[key]),
direction =
Number(SliderKeyDirection[key]) *
(this.#isRTL() && /^(Arrow)?(Left|Right)$/.test(key) ? -1 : 1),
diff = modifiedStep * direction,
currentValue = this.#repeatedKeys ? pointerValue() : (this.#delegate.getValue?.() ?? value()),
steps = (currentValue + diff) / step;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useMediaContext, type MediaContext } from '../../../../core/api/media-c
import type { MediaRequestEvents } from '../../../../core/api/media-request-events';
import type { TextTrack } from '../../../../core/tracks/text/text-track';
import { watchActiveTextTrack } from '../../../../core/tracks/text/utils';
import { setAttributeIfEmpty } from '../../../../utils/dom';
import { getLang, setAttributeIfEmpty } from '../../../../utils/dom';
import { round } from '../../../../utils/number';
import { formatSpokenTime, formatTime, type FormatTimeOptions } from '../../../../utils/time';
import type { SliderCSSVars } from '../slider/api/cssvars';
Expand Down Expand Up @@ -49,6 +49,7 @@ export class TimeSlider extends Component<
pauseWhileDragging: false,
noSwipeGesture: false,
seekingRequestThrottle: 100,
translations: null,
};

static state = sliderState;
Expand Down Expand Up @@ -236,10 +237,15 @@ export class TimeSlider extends Component<

#getARIAValueText(): string {
const time = this.#percentToTime(this.$state.value()),
{ duration } = this.#media.$state;
{ duration } = this.#media.$state,
lang = getLang(this.el);
return Number.isFinite(time)
? `${formatSpokenTime(time)} out of ${formatSpokenTime(duration())}`
: 'live';
? `${formatSpokenTime(time, lang)} ${this.#translate('out of')} ${formatSpokenTime(duration(), lang)}`
: this.#translate('live');
}

#translate(word: TimeSliderWord) {
return this.$props.translations()?.[word] ?? word;
}

// -------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -298,8 +304,18 @@ export interface TimeSliderProps extends SliderControllerProps {
* seek backwards or forwards, without directly interacting with time slider.
*/
noSwipeGesture: boolean;
/**
* Translations for the words in the slider's accessible value text.
*/
translations: Partial<TimeSliderTranslations> | null;
}

export type TimeSliderWord = 'out of' | 'live';

export type TimeSliderTranslations = {
[word in TimeSliderWord]: string;
};

interface ThrottledSeeking {
(time: number, event: Event): void;
cancel(): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function DefaultTimeSlider() {
key-step=${$signal(seekStep)}
?disabled=${$isDisabled}
?no-swipe-gesture=${$signal(noScrubGesture)}
.translations=${$signal(translations)}
${ref($ref.set)}
>
<media-slider-chapters class="vds-slider-chapters" ?disabled=${$isChaptersDisabled}>
Expand Down
7 changes: 7 additions & 0 deletions packages/vidstack/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export function scheduleRafJob(job: () => void) {
return () => rafJobs.delete(job);
}

/**
* The language of the given element, from its closest `lang` attribute.
*/
export function getLang(el: Element | null | undefined): string | null {
return el?.closest('[lang]')?.getAttribute('lang') || null;
}

export function setAttributeIfEmpty(target: Element, name: string, value: string) {
if (!target.hasAttribute(name)) target.setAttribute(name, value);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/vidstack/src/utils/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ describe(formatSpokenTime.name, function () {
expect(formatSpokenTime(5025)).to.equal('1 hour 23 min 45 sec');
expect(formatSpokenTime(45296)).to.equal('12 hour 34 min 56 sec');
});

it('it should spell out spoken time in the given language', function () {
expect(formatSpokenTime(0, 'en')).to.equal('0 seconds');
expect(formatSpokenTime(83, 'en')).to.equal('1 minute, 23 seconds');
expect(formatSpokenTime(3605, 'en')).to.equal('1 hour, 5 seconds');
expect(formatSpokenTime(120, 'ar')).to.equal('دقيقتان');
expect(formatSpokenTime(65, 'he')).to.equal('1 דקה, 5 שניות');
});

it('it should fall back to the unlocalized form for an invalid language', function () {
expect(formatSpokenTime(83, '%%')).to.equal('1 min 23 sec');
});
});

describe(formatHtml5Duration.name, function () {
Expand Down
20 changes: 17 additions & 3 deletions packages/vidstack/src/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,29 @@ export function formatTime(
}

/**
* Formats the given `duration` into human spoken form.
* Formats the given `duration` into human spoken form. Given a `lang`, the units are spelled out in
* that language by `Intl.DurationFormat` (CLDR plurals included) where the browser supports it.
*
* @param duration - The length of time to parse in seconds.
* @example `2 hour 3 min 4 sec`
* @param lang - A BCP 47 language tag, e.g. the closest `lang` attribute of the component.
* @example `2 hour 3 min 4 sec`, or `2 hours, 3 minutes, 4 seconds` for `en`
*/
export function formatSpokenTime(duration: number): string {
export function formatSpokenTime(duration: number, lang?: string | null): string {
const spokenParts: string[] = [];
const { hours, minutes, seconds } = parseTime(duration);

const DurationFormat = (Intl as any).DurationFormat;
if (lang && DurationFormat) {
try {
return new DurationFormat(lang, {
style: 'long',
secondsDisplay: hours > 0 || minutes > 0 ? 'auto' : 'always',
}).format({ hours, minutes, seconds });
} catch {
// An invalid language tag falls back to the unlocalized form below.
}
}

if (hours > 0) {
spokenParts.push(`${hours} hour`);
}
Expand Down
Loading