diff --git a/packages/react/src/components/layouts/default/ui/sliders.tsx b/packages/react/src/components/layouts/default/ui/sliders.tsx index 586d13b65..162f359eb 100644 --- a/packages/react/src/components/layouts/default/ui/sliders.tsx +++ b/packages/react/src/components/layouts/default/ui/sliders.tsx @@ -78,8 +78,14 @@ function DefaultTimeSlider() { const [instance, setInstance] = React.useState(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); @@ -97,6 +103,7 @@ function DefaultTimeSlider() { disabled={disableTimeSlider} noSwipeGesture={noScrubGesture} keyStep={seekStep} + translations={translations} ref={setInstance} > = 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}`, diff --git a/packages/vidstack/src/components/layouts/default/translations.ts b/packages/vidstack/src/components/layouts/default/translations.ts index 55d42c308..541f1c7f6 100644 --- a/packages/vidstack/src/components/layouts/default/translations.ts +++ b/packages/vidstack/src/components/layouts/default/translations.ts @@ -31,10 +31,12 @@ export type DefaultLayoutWord = | 'Google Cast' | 'Keyboard Animations' | 'LIVE' + | 'live' | 'Loop' | 'Mute' | 'Normal' | 'Off' + | 'out of' | 'Pause' | 'Play' | 'Playback' diff --git a/packages/vidstack/src/components/ui/menu/radio-groups/chapters-radio-group.ts b/packages/vidstack/src/components/ui/menu/radio-groups/chapters-radio-group.ts index 8b646a2da..2b51e2174 100644 --- a/packages/vidstack/src/components/ui/menu/radio-groups/chapters-radio-group.ts +++ b/packages/vidstack/src/components/ui/menu/radio-groups/chapters-radio-group.ts @@ -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'; @@ -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), ), })); } diff --git a/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts b/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts index f603e3223..e15ad1332 100644 --- a/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts +++ b/packages/vidstack/src/components/ui/sliders/slider/events-controller.ts @@ -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(), @@ -208,7 +217,7 @@ 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 = @@ -216,8 +225,10 @@ export class SliderEventsController extends ViewController< } 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; } } @@ -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; diff --git a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts index 443df7fc1..bc6a43ed9 100644 --- a/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts +++ b/packages/vidstack/src/components/ui/sliders/time-slider/time-slider.ts @@ -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'; @@ -49,6 +49,7 @@ export class TimeSlider extends Component< pauseWhileDragging: false, noSwipeGesture: false, seekingRequestThrottle: 100, + translations: null, }; static state = sliderState; @@ -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; } // ------------------------------------------------------------------------------------------- @@ -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 | null; } +export type TimeSliderWord = 'out of' | 'live'; + +export type TimeSliderTranslations = { + [word in TimeSliderWord]: string; +}; + interface ThrottledSeeking { (time: number, event: Event): void; cancel(): void; diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts b/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts index ea74a16b7..2a288d4a9 100644 --- a/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts +++ b/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts @@ -87,6 +87,7 @@ export function DefaultTimeSlider() { key-step=${$signal(seekStep)} ?disabled=${$isDisabled} ?no-swipe-gesture=${$signal(noScrubGesture)} + .translations=${$signal(translations)} ${ref($ref.set)} > diff --git a/packages/vidstack/src/utils/dom.ts b/packages/vidstack/src/utils/dom.ts index eeea80e7d..66952fd46 100644 --- a/packages/vidstack/src/utils/dom.ts +++ b/packages/vidstack/src/utils/dom.ts @@ -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); } diff --git a/packages/vidstack/src/utils/time.test.ts b/packages/vidstack/src/utils/time.test.ts index 2c621ea32..b5805dfad 100644 --- a/packages/vidstack/src/utils/time.test.ts +++ b/packages/vidstack/src/utils/time.test.ts @@ -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 () { diff --git a/packages/vidstack/src/utils/time.ts b/packages/vidstack/src/utils/time.ts index e86a52a06..77f243c0e 100644 --- a/packages/vidstack/src/utils/time.ts +++ b/packages/vidstack/src/utils/time.ts @@ -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`); } diff --git a/packages/vidstack/styles/player/default/menus.css b/packages/vidstack/styles/player/default/menus.css index 9f40cacf7..4a7ac0fd2 100644 --- a/packages/vidstack/styles/player/default/menus.css +++ b/packages/vidstack/styles/player/default/menus.css @@ -415,7 +415,7 @@ user-select: none; display: flex; align-items: center; - justify-content: left; + justify-content: flex-start; width: 100%; appearance: none; border: 0; @@ -469,7 +469,7 @@ :where(.vds-menu-item[aria-expanded='true']) { position: sticky; top: calc(-1 * var(--root-padding)); - left: 0; + inset-inline-start: 0; width: 100%; z-index: 10; backdrop-filter: blur(4px); @@ -495,7 +495,7 @@ --size: var(--item-icon-size); width: var(--size); height: var(--size); - margin-right: var(--media-menu-item-icon-spacing, 6px); + margin-inline-end: var(--media-menu-item-icon-spacing, 6px); } :where(.vds-menu-open-icon, .vds-menu-close-icon) { @@ -511,17 +511,17 @@ } :where(.vds-menu-items .vds-menu-open-icon) { - margin-right: 0; + margin-inline-end: 0; } :where(.vds-menu-items) :where(.vds-menu-item-hint, .vds-menu-open-icon) { - margin-left: auto; + margin-inline-start: auto; } :where(.vds-menu-items) :where(.vds-menu-item-hint + .vds-menu-open-icon), :where(.vds-menu-item-hint + media-icon .vds-menu-open-icon), :where(.vds-menu-item-hint + slot > .vds-menu-open-icon) { - margin-left: 2px; + margin-inline-start: 2px; } :where(.vds-menu-item[aria-hidden='true']), @@ -542,7 +542,11 @@ :where(.vds-menu-item[aria-expanded='true'] .vds-menu-close-icon) { display: inline !important; - margin-left: calc(-1 * var(--item-padding) / 2); + margin-inline-start: calc(-1 * var(--item-padding) / 2); +} + +:where(.vds-menu-open-icon, .vds-menu-close-icon):dir(rtl) { + transform: scaleX(-1); } /* * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -617,6 +621,21 @@ transform: translateX(var(--checkbox-gap)); } +:where(.vds-menu-checkbox:dir(rtl))::after { + right: 0; + transform: translateX(calc(-1 * var(--checkbox-gap))); +} + +:where(.vds-menu-checkbox[aria-checked='true']:dir(rtl))::after { + transform: translateX( + calc(-1 * (var(--checkbox-width) - var(--checkbox-diameter) - var(--checkbox-gap))) + ); +} + +:where(.vds-menu-checkbox[aria-checked='true'][data-active]:dir(rtl))::after { + transform: translateX(calc(-1 * var(--checkbox-gap))); +} + /* * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Slider @@ -699,11 +718,11 @@ .vds-radio { cursor: pointer; contain: content; - padding-left: calc(var(--item-icon-size) + var(--item-padding)); + padding-inline-start: calc(var(--item-icon-size) + var(--item-padding)); } .vds-radio[aria-checked='true'] { - padding-left: 0; + padding-inline-start: 0; } .vds-radio .vds-icon { @@ -713,11 +732,11 @@ .vds-radio[aria-checked='true'] .vds-icon { display: inline-block; - margin-left: 6px; + margin-inline-start: 6px; } :where(.vds-radio-hint) { - margin-left: auto; + margin-inline-start: auto; } /* @@ -849,7 +868,7 @@ } .vds-chapter-radio[aria-checked='true'] { - padding-left: var(--item-padding); + padding-inline-start: var(--item-padding); } :where(.vds-chapter-radio:last-child) { @@ -858,7 +877,7 @@ .vds-chapter-radio[aria-checked='true'] { background-color: var(--chapter-active-bg); - border-left: var(--chapter-active-border-left); + border-inline-start: var(--chapter-active-border-left); } :where(.vds-chapter-radio[aria-checked='true']):after { @@ -867,7 +886,7 @@ height: var(--media-chapters-progress-height, 4px); position: absolute; bottom: 0; - left: 0; + inset-inline-start: 0; } .vds-chapter-radio[aria-checked='true']:after { @@ -876,7 +895,7 @@ } .vds-chapters-radio-group :where(.vds-thumbnail) { - margin-right: var(--media-chapters-thumbnail-gap, 12px); + margin-inline-end: var(--media-chapters-thumbnail-gap, 12px); flex-shrink: 0; min-width: var(--media-chapters-thumbnail-min-width, 100px); min-height: var(--media-chapters-thumbnail-min-height, 56px); @@ -946,7 +965,7 @@ :where(.vds-chapters-radio-group:not([data-thumbnails]) .vds-chapter-radio-start-time) { margin-top: 0; - margin-left: auto; + margin-inline-start: auto; } :where(.vds-chapters-radio-group:not([data-thumbnails]) .vds-chapter-radio-duration) { diff --git a/packages/vidstack/styles/player/default/sliders.css b/packages/vidstack/styles/player/default/sliders.css index c0df6f39e..c35fded76 100644 --- a/packages/vidstack/styles/player/default/sliders.css +++ b/packages/vidstack/styles/player/default/sliders.css @@ -389,3 +389,19 @@ color: var(--media-slider-chapter-title-color, #f5f5f5); background-color: var(--media-slider-chapter-title-bg); } + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Right-to-left + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +/* Horizontal sliders run from right (min) to left (max). The preview is mirrored back so its + * text and thumbnail read normally. `scale` leaves the `transform` set on these elements alone. */ +:where(.vds-slider:not([aria-orientation='vertical']):dir(rtl)) { + scale: -1 1; +} + +:where(.vds-slider:not([aria-orientation='vertical']):dir(rtl) .vds-slider-preview) { + scale: -1 1; +}