From 0944d2e82d38a06a10ebeb608c68113d5096db9b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 6 May 2025 18:03:50 +0200 Subject: [PATCH 01/54] Set up internationalization --- src/UIContainer.ts | 30 ++++++++++++++++++++++++++++ src/components/StateReceiverMixin.ts | 1 + src/i18n/Locale.ts | 23 +++++++++++++++++++++ src/i18n/index.ts | 1 + src/index.ts | 1 + src/util/Attribute.ts | 3 ++- src/util/CommonUtils.ts | 9 +++++++++ 7 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/i18n/Locale.ts create mode 100644 src/i18n/index.ts diff --git a/src/UIContainer.ts b/src/UIContainer.ts index a2b75e9f..f215d8ed 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -7,6 +7,7 @@ import elementCss from './UIContainer.css'; import { arrayFind, arrayRemove, + closestRecursive, containsComposedNode, getFocusableChildren, getSlottedElements, @@ -38,6 +39,7 @@ import { isArrowKey, isBackKey, KeyCode } from './util/KeyCode'; import { READY_EVENT } from './events/ReadyEvent'; import { addGlobalStyles } from './Global'; import { ACCIDENTAL_CLICK_DELAY } from './util/Constants'; +import { type addLocale, type Locale } from './i18n'; // Load components used in template import './components/GestureReceiver'; @@ -140,6 +142,7 @@ export class UIContainer extends LitElement { private _deviceType: DeviceType = 'desktop'; private _streamType: StreamType = 'vod'; private _fluid: boolean = false; + private _language: string = ''; private _userIdle: boolean = false; private _userIdleTimeout: number | undefined = undefined; private _isUserActive: boolean = false; @@ -244,6 +247,27 @@ export class UIContainer extends LitElement { this._updateAspectRatio(); } + /** + * The language of this element. + * + * When set, this also updates the {@link Locale} of the UI if one is registered with {@link addLocale}. + * + * @see HTMLElement.lang + */ + get lang(): string { + return this._language; + } + + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + set lang(value: string | null) { + this._language = value ?? ''; + for (const receiver of this._stateReceivers) { + if (receiver[StateReceiverProps].indexOf('lang') >= 0) { + receiver.lang = this._language; + } + } + } + /** * Whether the player's audio is muted. */ @@ -485,6 +509,9 @@ export class UIContainer extends LitElement { super.connectedCallback(); addGlobalStyles(); + if (!this.hasAttribute(Attribute.LANG)) { + this.lang = closestRecursive(this, '[lang]')?.lang ?? ''; + } if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { this.deviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; } @@ -651,6 +678,9 @@ export class UIContainer extends LitElement { if (receiverProps.indexOf('player') >= 0) { receiver.player = this._player; } + if (receiverProps.indexOf('lang') >= 0) { + receiver.lang = this.lang; + } if (receiverProps.indexOf('fullscreen') >= 0) { receiver.fullscreen = this.fullscreen; } diff --git a/src/components/StateReceiverMixin.ts b/src/components/StateReceiverMixin.ts index e286496c..e66af507 100644 --- a/src/components/StateReceiverMixin.ts +++ b/src/components/StateReceiverMixin.ts @@ -16,6 +16,7 @@ export interface StateReceiverPropertyMap { targetVideoQualities: VideoQuality[] | undefined; error: THEOplayerError | undefined; previewTime: number; + lang: string; } /** diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts new file mode 100644 index 00000000..a59c6787 --- /dev/null +++ b/src/i18n/Locale.ts @@ -0,0 +1,23 @@ +const localesByName: Record = {}; + +export interface Locale {} + +const defaultLocale: Locale = {}; + +export function getLocale(name: string): Locale { + return localesByName[name] ?? defaultLocale; +} + +/** + * Register a new locale with the given name. + * + * The locale's name should preferably be a BCP 47 language identifier, so it can be set as + * a valid [`lang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang) + * of the UI. + * + * @param name The name of the locale. + * @param locale The locale. + */ +export function addLocale(name: string, locale: Partial) { + localesByName[name] = { ...defaultLocale, ...locale }; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..28d40166 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1 @@ +export * from './Locale'; diff --git a/src/index.ts b/src/index.ts index 9459521d..4bf5f3eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,5 @@ export { type DeviceType } from './util/DeviceType'; export { type StreamType } from './util/StreamType'; export { type Constructor } from './util/CommonUtils'; export { ColorStops } from './util/ColorStops'; +export { type Locale, addLocale } from './i18n/index'; export * from './version'; diff --git a/src/util/Attribute.ts b/src/util/Attribute.ts index f97f8fa7..4ca445ac 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -52,5 +52,6 @@ export enum Attribute { SHOW_AD_MARKERS = 'show-ad-markers', CLICKTHROUGH = 'clickthrough', PROPERTY = 'property', - VALUE = 'value' + VALUE = 'value', + LANG = 'lang' } diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index 35b50a59..d3658f36 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -259,3 +259,12 @@ export function upgradeCustomElementIfNeeded(element: Element): Promise } return undefined; } + +export function closestRecursive(element: Element, selector: string): E | null { + if (!element.closest) return null; + const closest = element.closest(selector); + if (closest) return closest; + const host = (element.getRootNode?.() as ShadowRoot | null)?.host; + if (host) return closestRecursive(host, selector); + return null; +} From c110d1f3449fc0f0279bbe844ea5043326cc8b3e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 6 May 2025 18:20:49 +0200 Subject: [PATCH 02/54] Add lang to DefaultUI --- src/DefaultUI.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index 52211ab7..5f3cc9c8 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -3,7 +3,12 @@ import { customElement, property, queryAssignedNodes, state } from 'lit/decorato import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { ChromelessPlayer, SourceDescription, UIPlayerConfiguration } from 'theoplayer/chromeless'; -import { DEFAULT_DVR_THRESHOLD, DEFAULT_TV_USER_IDLE_TIMEOUT, DEFAULT_USER_IDLE_TIMEOUT, type UIContainer } from './UIContainer'; +import { + DEFAULT_DVR_THRESHOLD, + DEFAULT_TV_USER_IDLE_TIMEOUT, + DEFAULT_USER_IDLE_TIMEOUT, + type UIContainer +} from './UIContainer'; import defaultUiCss from './DefaultUI.css'; import { Attribute } from './util/Attribute'; import { applyExtensions } from './extensions/ExtensionRegistry'; @@ -13,6 +18,7 @@ import type { StreamType } from './util/StreamType'; import { USER_IDLE_CHANGE_EVENT } from './events/UserIdleChangeEvent'; import { READY_EVENT } from './events/ReadyEvent'; import { ACCIDENTAL_CLICK_DELAY } from './util/Constants'; +import { closestRecursive } from './util/CommonUtils'; import { createCustomEvent } from './util/EventUtils'; /** @@ -94,6 +100,7 @@ export class DefaultUI extends LitElement { private _configuration: UIPlayerConfiguration = {}; private _source: SourceDescription | undefined = undefined; + private _language: string = ''; private _userIdleTimeout: number | undefined = undefined; private _deviceType: DeviceType = 'desktop'; private _dvrThreshold: number = DEFAULT_DVR_THRESHOLD; @@ -183,6 +190,25 @@ export class DefaultUI extends LitElement { @property({ reflect: true, type: Boolean, attribute: Attribute.FLUID }) accessor fluid: boolean = false; + /** + * The language of this element. + * + * When set, this also updates the {@link Locale} of the UI if one is registered with {@link addLocale}. + * + * @see HTMLElement.lang + */ + get lang(): string { + return this._uiRef.value?.lang ?? this._language; + } + + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + set lang(value: string | null) { + this._language = value ?? ''; + if (this._uiRef.value) { + this._uiRef.value.lang = this._language; + } + } + /** * Whether the player's audio is muted. * @@ -281,6 +307,9 @@ export class DefaultUI extends LitElement { connectedCallback(): void { super.connectedCallback(); + if (!this.hasAttribute(Attribute.LANG)) { + this.lang = closestRecursive(this, '[lang]')?.lang ?? ''; + } if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { this.deviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; } @@ -337,6 +366,7 @@ export class DefaultUI extends LitElement { return html` Date: Tue, 6 May 2025 18:10:00 +0200 Subject: [PATCH 03/54] Internationalize PlayButton --- src/components/PlayButton.ts | 9 +++++++-- src/i18n/Locale.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/PlayButton.ts b/src/components/PlayButton.ts index bdde4575..cbc7038f 100644 --- a/src/components/PlayButton.ts +++ b/src/components/PlayButton.ts @@ -9,6 +9,7 @@ import pauseIcon from '../icons/pause.svg'; import replayIcon from '../icons/replay.svg'; import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; const PLAYER_EVENTS = ['seeking', 'seeked', 'ended', 'emptied', 'sourcechange'] as const; @@ -19,7 +20,7 @@ const PLAYER_EVENTS = ['seeking', 'seeked', 'ended', 'emptied', 'sourcechange'] * Overrides `--theoplayer-icon-color` for this button. Defaults to `unset`. */ @customElement('theoplayer-play-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class PlayButton extends Button { static styles = [...Button.styles, playButtonCss]; @@ -47,6 +48,9 @@ export class PlayButton extends Button { @property({ reflect: true, state: true, type: Boolean, attribute: Attribute.ENDED }) accessor ended: boolean = false; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } @@ -115,7 +119,8 @@ export class PlayButton extends Button { } private _updateAriaLabel(): void { - const label = this.ended ? 'replay' : this.paused ? 'play' : 'pause'; + const locale = getLocale(this.lang); + const label = this.ended ? locale.replayAria : this.paused ? locale.playAria : locale.pauseAria; this.setAttribute(Attribute.ARIA_LABEL, label); } diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index a59c6787..bff28fee 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,8 +1,16 @@ const localesByName: Record = {}; -export interface Locale {} +export interface Locale { + playAria: string; + pauseAria: string; + replayAria: string; +} -const defaultLocale: Locale = {}; +const defaultLocale: Locale = { + playAria: 'play', + pauseAria: 'pause', + replayAria: 'replay' +}; export function getLocale(name: string): Locale { return localesByName[name] ?? defaultLocale; From 4ccf9962e9ff20bd4c9e64e09eb5461f831eecb2 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 21 Jan 2026 17:53:22 +0100 Subject: [PATCH 04/54] Internationalize TimeDisplay --- src/components/TimeDisplay.ts | 11 ++++++++--- src/definitions.d.ts | 32 ++++++++++++++++++++++++++++++++ src/i18n/DurationFormatter.ts | 26 ++++++++++++++++++++++++++ src/i18n/Locale.ts | 25 ++++++++++++++++++++++--- src/util/CommonUtils.ts | 12 ++++++++++++ src/util/TimeUtils.ts | 27 +++++---------------------- 6 files changed, 105 insertions(+), 28 deletions(-) create mode 100644 src/i18n/DurationFormatter.ts diff --git a/src/components/TimeDisplay.ts b/src/components/TimeDisplay.ts index 1aebcf95..cd15cda8 100644 --- a/src/components/TimeDisplay.ts +++ b/src/components/TimeDisplay.ts @@ -6,6 +6,7 @@ import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { formatAsTimePhrase, formatTime } from '../util/TimeUtils'; import { Attribute } from '../util/Attribute'; import type { StreamType } from '../util/StreamType'; +import { getLocale } from '../i18n'; const PLAYER_EVENTS = ['timeupdate', 'seeking', 'seeked', 'durationchange'] as const; @@ -15,7 +16,7 @@ const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time'; * A control that displays the current time of the stream. */ @customElement('theoplayer-time-display') -@stateReceiver(['player', 'streamType']) +@stateReceiver(['player', 'streamType', 'lang']) export class TimeDisplay extends LitElement { static override styles = [textDisplayCss]; @@ -79,6 +80,9 @@ export class TimeDisplay extends LitElement { @property({ reflect: true, type: String, attribute: Attribute.STREAM_TYPE }) accessor streamType: StreamType = 'vod'; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + @state() private accessor _currentTime: number = 0; @@ -99,6 +103,7 @@ export class TimeDisplay extends LitElement { }; protected override render(): HTMLTemplateResult { + const locale = getLocale(this.lang); const remaining = this.remaining || (this.remainingWhenLive && this.streamType !== 'vod'); let time = this._currentTime; const endTime = this._endTime; @@ -116,9 +121,9 @@ export class TimeDisplay extends LitElement { if (isNaN(this._duration)) { ariaValueText = DEFAULT_MISSING_TIME_PHRASE; } else if (this.showDuration) { - ariaValueText = `${formatAsTimePhrase(time, remaining)} of ${formatAsTimePhrase(endTime)}`; + ariaValueText = `${formatAsTimePhrase(locale, time, remaining)} of ${formatAsTimePhrase(locale, endTime)}`; } else { - ariaValueText = formatAsTimePhrase(time, remaining); + ariaValueText = formatAsTimePhrase(locale, time, remaining); } this.setAttribute('aria-valuetext', ariaValueText); diff --git a/src/definitions.d.ts b/src/definitions.d.ts index 5708ed06..ed1af484 100644 --- a/src/definitions.d.ts +++ b/src/definitions.d.ts @@ -13,3 +13,35 @@ declare module '*.svg' { const svgText: string; export default svgText; } + +declare namespace Intl { + type DurationTimeFormatLocaleMatcher = 'lookup' | 'best fit'; + type DurationFormatStyle = 'long' | 'short' | 'narrow' | 'digital'; + + interface DurationFormatOptions { + localeMatcher?: DurationTimeFormatLocaleMatcher; + numberingSystem?: string; + style?: DurationFormatStyle; + secondsDisplay?: 'always' | 'auto'; + } + + interface DurationFormatInput { + years?: number; + months?: number; + weeks?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; + microseconds?: number; + nanoseconds?: number; + } + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat + class DurationFormat { + constructor(locales?: LocalesArgument, options?: DurationFormatOptions); + + format(duration: DurationFormatInput): string; + } +} diff --git a/src/i18n/DurationFormatter.ts b/src/i18n/DurationFormatter.ts new file mode 100644 index 00000000..1acc817d --- /dev/null +++ b/src/i18n/DurationFormatter.ts @@ -0,0 +1,26 @@ +import type { Duration, DurationFormatter } from './Locale'; + +export function durationFormatterForLocale(locale: string): DurationFormatter { + try { + // Use Intl.DurationFormat (if supported). + const formatter = new Intl.DurationFormat(locale, { style: 'long' }); + const zeroFormat = new Intl.DurationFormat(locale, { style: 'long', secondsDisplay: 'always' }).format({ seconds: 0 }); + return (duration) => { + // If duration is zero, shows "0 seconds". + return duration.hours === 0 && duration.minutes === 0 && duration.seconds === 0 ? zeroFormat : formatter.format(duration); + }; + } catch { + // Fall back to English. + return defaultFormatDuration; + } +} + +export function defaultFormatDuration({ hours, minutes, seconds }: Duration): string { + return [ + hours === 0 ? '' : `${hours} hour${hours === 1 ? '' : 's'}`, + minutes === 0 ? '' : `${minutes} minute${minutes === 1 ? '' : 's'}`, + seconds === 0 && (hours > 0 || minutes > 0) ? '' : `${seconds} second${seconds === 1 ? '' : 's'}` + ] + .filter((part) => part !== '') + .join(', '); +} diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index bff28fee..3eb634fe 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,15 +1,30 @@ -const localesByName: Record = {}; +import { durationFormatterForLocale } from './DurationFormatter'; + +const localesByName: Record> = {}; export interface Locale { playAria: string; pauseAria: string; replayAria: string; + formatDuration: (duration: Duration) => string; + formatRemainingDuration: (duration: string) => string; +} + +export type DurationFormatter = (duration: Duration) => string; + +export interface Duration { + hours: number; + minutes: number; + seconds: number; } +const defaultLocaleName = 'en'; const defaultLocale: Locale = { playAria: 'play', pauseAria: 'pause', - replayAria: 'replay' + replayAria: 'replay', + formatDuration: durationFormatterForLocale(defaultLocaleName), + formatRemainingDuration: (duration: string) => `${duration} remaining` }; export function getLocale(name: string): Locale { @@ -27,5 +42,9 @@ export function getLocale(name: string): Locale { * @param locale The locale. */ export function addLocale(name: string, locale: Partial) { - localesByName[name] = { ...defaultLocale, ...locale }; + localesByName[name] = { + ...defaultLocale, + ...locale, + formatDuration: locale.formatDuration ?? durationFormatterForLocale(name) + }; } diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index d3658f36..dc0b9720 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -7,6 +7,18 @@ export function noOp(): void { return; } +export function lazy(fn: () => T): () => T { + let value: T | undefined; + return () => { + if (fn !== undefined) { + value = fn(); + // Allow the producer to be garbage collected. + fn = undefined!; + } + return value!; + }; +} + export function isElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } diff --git a/src/util/TimeUtils.ts b/src/util/TimeUtils.ts index 6fa4f20a..158782b4 100644 --- a/src/util/TimeUtils.ts +++ b/src/util/TimeUtils.ts @@ -1,3 +1,5 @@ +import type { Locale } from '../i18n'; + function isValidNumber(x: number): boolean { return !isNaN(x) && isFinite(x); } @@ -33,18 +35,7 @@ export function formatTime(time: number, guide: number = 0, preferNegative?: boo return `${negative ? '-' : ''}${timePhrase}`; } -const timeUnitLabels = [ - ['hour', 'hours'], - ['minute', 'minutes'], - ['second', 'seconds'] -] as const; - -export function toTimeUnitPhrase(timeUnitValue: number, unitIndex: 0 | 1 | 2): string { - const unitLabel = timeUnitLabels[unitIndex][timeUnitValue === 1 ? 0 : 1]; - return `${timeUnitValue} ${unitLabel}`; -} - -export function formatAsTimePhrase(time: number, preferNegative?: boolean): string { +export function formatAsTimePhrase(locale: Locale, time: number, preferNegative?: boolean): string { if (!isValidNumber(time)) return ''; const negative = time < 0 || (preferNegative && time === 0); time = Math.abs(time); @@ -53,16 +44,8 @@ export function formatAsTimePhrase(time: number, preferNegative?: boolean): stri const minutes = Math.floor(time / 60) % 60; const hours = Math.floor(time / 3600); - const timePhrase = [ - hours === 0 ? '' : toTimeUnitPhrase(hours, 0), - minutes === 0 ? '' : toTimeUnitPhrase(minutes, 1), - seconds === 0 && (hours > 0 || minutes > 0) ? '' : toTimeUnitPhrase(seconds, 2) - ] - .filter((part) => part !== '') - .join(', '); + const duration = locale.formatDuration({ hours, minutes, seconds }); // If the time was negative, assume it represents some remaining amount of time/"count down". - const negativeSuffix = negative ? ' remaining' : ''; - - return `${timePhrase}${negativeSuffix}`; + return negative ? locale.formatRemainingDuration(duration) : duration; } From daf0d6612d51707c379820ce8beaf94ff0e123f9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 21 Jan 2026 17:56:37 +0100 Subject: [PATCH 05/54] Internationalize TimeRange --- src/components/TimeRange.ts | 11 ++++++++--- src/i18n/Locale.ts | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/TimeRange.ts b/src/components/TimeRange.ts index 16ba942b..040b8d33 100644 --- a/src/components/TimeRange.ts +++ b/src/components/TimeRange.ts @@ -15,6 +15,7 @@ import type { StreamType } from '../util/StreamType'; import { isLinearAd } from '../util/AdUtils'; import type { ColorStops } from '../util/ColorStops'; import { KeyCode } from '../util/KeyCode'; +import { getLocale } from '../i18n'; // Load components used in template import './PreviewThumbnail'; @@ -118,6 +119,9 @@ export class TimeRange extends Range { this._ads?.addEventListener(AD_EVENTS, this._onAdChange); } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + /** * The stream type, either "vod", "live" or "dvr". */ @@ -182,12 +186,13 @@ export class TimeRange extends Range { } protected override getAriaLabel(): string { - return 'seek'; + return getLocale(this.lang).seekAria; } protected override getAriaValueText(): string { - const currentTimePhrase = formatAsTimePhrase(this.value); - const totalTimePhrase = formatAsTimePhrase(this.max); + const locale = getLocale(this.lang); + const currentTimePhrase = formatAsTimePhrase(locale, this.value); + const totalTimePhrase = formatAsTimePhrase(locale, this.max); if (currentTimePhrase && totalTimePhrase) { return `${currentTimePhrase} of ${totalTimePhrase}`; } diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 3eb634fe..6332684b 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -6,6 +6,7 @@ export interface Locale { playAria: string; pauseAria: string; replayAria: string; + seekAria: string; formatDuration: (duration: Duration) => string; formatRemainingDuration: (duration: string) => string; } @@ -23,6 +24,7 @@ const defaultLocale: Locale = { playAria: 'play', pauseAria: 'pause', replayAria: 'replay', + seekAria: 'seek', formatDuration: durationFormatterForLocale(defaultLocaleName), formatRemainingDuration: (duration: string) => `${duration} remaining` }; From 60cdde64bfd3c2d98e08872441bf999d8c101b9a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 17:36:57 +0200 Subject: [PATCH 06/54] Use `Intl.DurationFormat` types from `es2025.intl` --- src/definitions.d.ts | 32 -------------------------------- tsconfig.json | 2 +- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/src/definitions.d.ts b/src/definitions.d.ts index ed1af484..5708ed06 100644 --- a/src/definitions.d.ts +++ b/src/definitions.d.ts @@ -13,35 +13,3 @@ declare module '*.svg' { const svgText: string; export default svgText; } - -declare namespace Intl { - type DurationTimeFormatLocaleMatcher = 'lookup' | 'best fit'; - type DurationFormatStyle = 'long' | 'short' | 'narrow' | 'digital'; - - interface DurationFormatOptions { - localeMatcher?: DurationTimeFormatLocaleMatcher; - numberingSystem?: string; - style?: DurationFormatStyle; - secondsDisplay?: 'always' | 'auto'; - } - - interface DurationFormatInput { - years?: number; - months?: number; - weeks?: number; - days?: number; - hours?: number; - minutes?: number; - seconds?: number; - milliseconds?: number; - microseconds?: number; - nanoseconds?: number; - } - - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat - class DurationFormat { - constructor(locales?: LocalesArgument, options?: DurationFormatOptions); - - format(duration: DurationFormatInput): string; - } -} diff --git a/tsconfig.json b/tsconfig.json index 1ddf70b6..29e870de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "useDefineForClassFields": true, "isolatedModules": true, "verbatimModuleSyntax": true, - "lib": ["dom", "es2015", "es2020.intl"], + "lib": ["dom", "es2015", "es2020.intl", "es2025.intl"], "types": [] }, "include": ["src/**/*"] From c0db7d987d1fd2f9b369001a265737f164119bc5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 17:37:13 +0200 Subject: [PATCH 07/54] Add missing exports --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 4bf5f3eb..28fb414e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,5 +8,5 @@ export { type DeviceType } from './util/DeviceType'; export { type StreamType } from './util/StreamType'; export { type Constructor } from './util/CommonUtils'; export { ColorStops } from './util/ColorStops'; -export { type Locale, addLocale } from './i18n/index'; +export { type Locale, type Duration, type DurationFormatter, addLocale } from './i18n/index'; export * from './version'; From 1bea8feb7969d184b304cf54a276f39eb3b527d3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 17:45:16 +0200 Subject: [PATCH 08/54] Document `Locale` interface --- src/i18n/Locale.ts | 40 ++++++++++++++++++++++++++++++++++++++-- typedoc.config.mjs | 5 +++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 6332684b..e183e270 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,14 +1,50 @@ import { durationFormatterForLocale } from './DurationFormatter'; +import type { PlayButton, TimeRange } from '../components'; const localesByName: Record> = {}; export interface Locale { + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link PlayButton} when it is showing a "play" button, + * i.e. when the player is paused. + */ playAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link PlayButton} when it is showing a "pause" button + * i.e. when the player is playing. + */ pauseAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link PlayButton} when it is showing a "replay" button + * i.e. when the player is paused at the end of the stream. + */ replayAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. + */ seekAria: string; - formatDuration: (duration: Duration) => string; - formatRemainingDuration: (duration: string) => string; + /** + * Formats the given time duration as a human-readable string. + * + * This is optional. If not provided, a default {@link Intl.DurationFormat} with the {@link Intl.DurationFormatStyle | `"long"` style} is used. + * + * Examples: + * - `{ seconds: 5 }` → "5 seconds" + * - `{ minutes: 2, seconds: 10 }` → "2 minutes and 10 seconds" + * + * @param duration A duration, compatible with {@link Intl.DurationFormat.format}. + */ + formatDuration(duration: Duration): string; + /** + * Formats the given remaining time duration as a human-readable string. + * + * Examples: + * - "5 seconds" → "5 seconds remaining" + * - "2 minutes and 10 seconds" → "2 minutes and 10 seconds remaining" + * + * @param duration A duration that was formatted with {@link formatDuration}. + */ + formatRemainingDuration(duration: string): string; } export type DurationFormatter = (duration: Duration) => string; diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 16b82f91..9cbe5c4a 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -28,6 +28,11 @@ export default { } }, externalSymbolLinkMappings: { + typescript: { + 'ARIAMixin.ariaLabel': 'https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label', + 'Intl.DurationFormatStyle': + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style' + }, 'lit-element': { LitElement: 'https://lit.dev/docs/api/LitElement/', '*': 'https://lit.dev/docs/' From f12dbed7ebc03cd7ccd20064463e5c3931060f82 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 17:49:56 +0200 Subject: [PATCH 09/54] Restructure --- src/i18n/Locale.ts | 28 ++-------------------------- src/i18n/LocaleRegistry.ts | 26 ++++++++++++++++++++++++++ src/i18n/index.ts | 3 ++- 3 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 src/i18n/LocaleRegistry.ts diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index e183e270..79e6b66c 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,8 +1,6 @@ import { durationFormatterForLocale } from './DurationFormatter'; import type { PlayButton, TimeRange } from '../components'; -const localesByName: Record> = {}; - export interface Locale { /** * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link PlayButton} when it is showing a "play" button, @@ -55,8 +53,8 @@ export interface Duration { seconds: number; } -const defaultLocaleName = 'en'; -const defaultLocale: Locale = { +export const defaultLocaleName = 'en'; +export const defaultLocale: Locale = { playAria: 'play', pauseAria: 'pause', replayAria: 'replay', @@ -64,25 +62,3 @@ const defaultLocale: Locale = { formatDuration: durationFormatterForLocale(defaultLocaleName), formatRemainingDuration: (duration: string) => `${duration} remaining` }; - -export function getLocale(name: string): Locale { - return localesByName[name] ?? defaultLocale; -} - -/** - * Register a new locale with the given name. - * - * The locale's name should preferably be a BCP 47 language identifier, so it can be set as - * a valid [`lang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang) - * of the UI. - * - * @param name The name of the locale. - * @param locale The locale. - */ -export function addLocale(name: string, locale: Partial) { - localesByName[name] = { - ...defaultLocale, - ...locale, - formatDuration: locale.formatDuration ?? durationFormatterForLocale(name) - }; -} diff --git a/src/i18n/LocaleRegistry.ts b/src/i18n/LocaleRegistry.ts new file mode 100644 index 00000000..d5abad60 --- /dev/null +++ b/src/i18n/LocaleRegistry.ts @@ -0,0 +1,26 @@ +import { defaultLocale, type Locale } from './Locale'; +import { durationFormatterForLocale } from './DurationFormatter'; + +const localesByName: Record> = {}; + +export function getLocale(name: string): Locale { + return localesByName[name] ?? defaultLocale; +} + +/** + * Register a new locale with the given name. + * + * The locale's name should preferably be a BCP 47 language identifier, so it can be set as + * a valid [`lang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang) + * of the UI. + * + * @param name The name of the locale. + * @param locale The locale. + */ +export function addLocale(name: string, locale: Partial) { + localesByName[name] = { + ...defaultLocale, + ...locale, + formatDuration: locale.formatDuration ?? durationFormatterForLocale(name) + }; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 28d40166..be163868 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1 +1,2 @@ -export * from './Locale'; +export { type Locale, type Duration, type DurationFormatter } from './Locale'; +export { getLocale, addLocale } from './LocaleRegistry'; From d27ecc28fb4d58f14b3f21ce56195222e0134529 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:06:01 +0200 Subject: [PATCH 10/54] Use `willUpdate()` to update ARIA labels --- src/components/AirPlayButton.ts | 9 ++++----- src/components/ChromecastButton.ts | 9 ++++----- src/components/FullscreenButton.ts | 10 ++++------ src/components/MuteButton.ts | 10 ++++------ src/components/PlayButton.ts | 10 ++++------ src/components/SeekButton.ts | 10 ++++------ 6 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/components/AirPlayButton.ts b/src/components/AirPlayButton.ts index 67af8ae1..74a981ee 100644 --- a/src/components/AirPlayButton.ts +++ b/src/components/AirPlayButton.ts @@ -1,3 +1,4 @@ +import { type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { stateReceiver } from './StateReceiverMixin'; @@ -30,11 +31,9 @@ export class AirPlayButton extends CastButton { this.castApi = player?.cast?.airplay; } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (AirPlayButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index f27026e4..ea57a2a2 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -1,3 +1,4 @@ +import { type PropertyValues } from 'lit'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; @@ -37,11 +38,9 @@ export class ChromecastButton extends CastButton { this.castApi = player?.cast?.chromecast; } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (ChromecastButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index abd3737d..dbdf3fe3 100644 --- a/src/components/FullscreenButton.ts +++ b/src/components/FullscreenButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; @@ -46,11 +46,9 @@ export class FullscreenButton extends Button { } } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (FullscreenButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { diff --git a/src/components/MuteButton.ts b/src/components/MuteButton.ts index d53fbfa5..136e637b 100644 --- a/src/components/MuteButton.ts +++ b/src/components/MuteButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; @@ -80,11 +80,9 @@ export class MuteButton extends Button { } } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (MuteButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { diff --git a/src/components/PlayButton.ts b/src/components/PlayButton.ts index cbc7038f..59617f8c 100644 --- a/src/components/PlayButton.ts +++ b/src/components/PlayButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; @@ -111,11 +111,9 @@ export class PlayButton extends Button { } } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (PlayButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index 2d0811cc..7d130cb3 100644 --- a/src/components/SeekButton.ts +++ b/src/components/SeekButton.ts @@ -1,4 +1,4 @@ -import { html } from 'lit'; +import { html, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Button } from './Button'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; @@ -53,11 +53,9 @@ export class SeekButton extends Button { this._player.currentTime = Math.max(0, Math.min(duration, this._player.currentTime + this.seekOffset)); } - override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { - super.attributeChangedCallback(attrName, oldValue, newValue); - if (SeekButton.observedAttributes.indexOf(attrName as Attribute) >= 0) { - this._updateAriaLabel(); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); } private _updateAriaLabel(): void { From 4889bad2a1c9b88b7eb56e93a179c508ce23fd1a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:11:56 +0200 Subject: [PATCH 11/54] Add `Button.ariaLabel` --- src/components/AirPlayButton.ts | 4 +--- src/components/Button.ts | 3 +++ src/components/ChromecastButton.ts | 4 +--- src/components/CloseMenuButton.ts | 5 ++--- src/components/FullscreenButton.ts | 3 +-- src/components/LanguageMenuButton.ts | 4 ++-- src/components/LiveButton.ts | 4 ++-- src/components/MuteButton.ts | 3 +-- src/components/PlayButton.ts | 3 +-- src/components/PlaybackRateMenuButton.ts | 5 ++--- src/components/SeekButton.ts | 3 +-- src/components/SettingsMenuButton.ts | 5 ++--- src/components/TimeDisplay.ts | 4 ++-- src/components/theolive/quality/BadNetworkModeButton.ts | 5 ++--- 14 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/components/AirPlayButton.ts b/src/components/AirPlayButton.ts index 74a981ee..557c50dc 100644 --- a/src/components/AirPlayButton.ts +++ b/src/components/AirPlayButton.ts @@ -5,7 +5,6 @@ import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; import airPlayButtonHtml from './AirPlayButton.html'; import airPlayButtonCss from './AirPlayButton.css'; -import { Attribute } from '../util/Attribute'; /** * A button to start and stop casting using AirPlay. @@ -37,8 +36,7 @@ export class AirPlayButton extends CastButton { } private _updateAriaLabel(): void { - const label = this.castState === 'connecting' || this.castState === 'connected' ? 'stop playing on AirPlay' : 'start playing on AirPlay'; - this.setAttribute(Attribute.ARIA_LABEL, label); + this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? 'stop playing on AirPlay' : 'start playing on AirPlay'; } protected override render() { diff --git a/src/components/Button.ts b/src/components/Button.ts index 26e9a293..22a567aa 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -113,6 +113,9 @@ export class Button extends LitElement { } } + @property({ reflect: true, state: true, type: String, attribute: Attribute.ARIA_LABEL }) + override accessor ariaLabel: string | null = null; + private _enable(): void { this.removeEventListener('click', this._onClick); this.removeEventListener('keydown', this._onKeyDown); diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index ea57a2a2..7797bc4f 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -4,7 +4,6 @@ import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; import chromecastButtonHtml from './ChromecastButton.html'; import chromecastButtonCss from './ChromecastButton.css'; -import { Attribute } from '../util/Attribute'; import { customElement } from 'lit/decorators.js'; let chromecastButtonId = 0; @@ -44,9 +43,8 @@ export class ChromecastButton extends CastButton { } private _updateAriaLabel(): void { - const label = + this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? 'stop casting to Chromecast' : 'start casting to Chromecast'; - this.setAttribute(Attribute.ARIA_LABEL, label); } protected override render() { diff --git a/src/components/CloseMenuButton.ts b/src/components/CloseMenuButton.ts index c935af97..588cfee4 100644 --- a/src/components/CloseMenuButton.ts +++ b/src/components/CloseMenuButton.ts @@ -5,7 +5,6 @@ import { Button } from './Button'; import backIcon from '../icons/back.svg'; import { createCustomEvent } from '../util/EventUtils'; import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent'; -import { Attribute } from '../util/Attribute'; /** * A button that closes its parent menu. @@ -17,8 +16,8 @@ export class CloseMenuButton extends Button { override connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'close menu'); + if (this.ariaLabel == null) { + this.ariaLabel = 'close menu'; } } diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index dbdf3fe3..38eb1f9b 100644 --- a/src/components/FullscreenButton.ts +++ b/src/components/FullscreenButton.ts @@ -52,8 +52,7 @@ export class FullscreenButton extends Button { } private _updateAriaLabel(): void { - const label = this.fullscreen ? 'exit fullscreen' : 'enter fullscreen'; - this.setAttribute(Attribute.ARIA_LABEL, label); + this.ariaLabel = this.fullscreen ? 'exit fullscreen' : 'enter fullscreen'; } protected override render(): HTMLTemplateResult { diff --git a/src/components/LanguageMenuButton.ts b/src/components/LanguageMenuButton.ts index db643470..ef5bf04e 100644 --- a/src/components/LanguageMenuButton.ts +++ b/src/components/LanguageMenuButton.ts @@ -26,8 +26,8 @@ export class LanguageMenuButton extends MenuButton { override connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open language menu'); + if (this.ariaLabel == null) { + this.ariaLabel = 'open language menu'; } } diff --git a/src/components/LiveButton.ts b/src/components/LiveButton.ts index 6a2b3e87..2330c7bd 100644 --- a/src/components/LiveButton.ts +++ b/src/components/LiveButton.ts @@ -32,8 +32,8 @@ export class LiveButton extends Button { connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'seek to live'); + if (this.ariaLabel == null) { + this.ariaLabel = 'seek to live'; } } diff --git a/src/components/MuteButton.ts b/src/components/MuteButton.ts index 136e637b..ed429820 100644 --- a/src/components/MuteButton.ts +++ b/src/components/MuteButton.ts @@ -86,8 +86,7 @@ export class MuteButton extends Button { } private _updateAriaLabel(): void { - const label = this.volumeLevel === 'off' ? 'unmute' : 'mute'; - this.setAttribute(Attribute.ARIA_LABEL, label); + this.ariaLabel = this.volumeLevel === 'off' ? 'unmute' : 'mute'; } protected override render(): HTMLTemplateResult { diff --git a/src/components/PlayButton.ts b/src/components/PlayButton.ts index 59617f8c..40dd466d 100644 --- a/src/components/PlayButton.ts +++ b/src/components/PlayButton.ts @@ -118,8 +118,7 @@ export class PlayButton extends Button { private _updateAriaLabel(): void { const locale = getLocale(this.lang); - const label = this.ended ? locale.replayAria : this.paused ? locale.playAria : locale.pauseAria; - this.setAttribute(Attribute.ARIA_LABEL, label); + this.ariaLabel = this.ended ? locale.replayAria : this.paused ? locale.playAria : locale.pauseAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/PlaybackRateMenuButton.ts b/src/components/PlaybackRateMenuButton.ts index 4aef34f5..a11d3077 100644 --- a/src/components/PlaybackRateMenuButton.ts +++ b/src/components/PlaybackRateMenuButton.ts @@ -3,7 +3,6 @@ import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import speedIcon from '../icons/speed.svg'; -import { Attribute } from '../util/Attribute'; /** * A menu button that opens a [playback rate menu]{@link PlaybackRateMenu}. @@ -15,8 +14,8 @@ export class PlaybackRateMenuButton extends MenuButton { override connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open playback speed menu'); + if (this.ariaLabel == null) { + this.ariaLabel = 'open playback speed menu'; } } diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index 7d130cb3..c94d50cd 100644 --- a/src/components/SeekButton.ts +++ b/src/components/SeekButton.ts @@ -60,8 +60,7 @@ export class SeekButton extends Button { private _updateAriaLabel(): void { const seekOffset = this.seekOffset; - const label = seekOffset >= 0 ? `seek forward by ${seekOffset} seconds` : `seek backward by ${-seekOffset} seconds`; - this.setAttribute(Attribute.ARIA_LABEL, label); + this.ariaLabel = seekOffset >= 0 ? `seek forward by ${seekOffset} seconds` : `seek backward by ${-seekOffset} seconds`; } protected override render() { diff --git a/src/components/SettingsMenuButton.ts b/src/components/SettingsMenuButton.ts index 08bcc82a..57d3425e 100644 --- a/src/components/SettingsMenuButton.ts +++ b/src/components/SettingsMenuButton.ts @@ -3,7 +3,6 @@ import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import settingsIcon from '../icons/settings.svg'; -import { Attribute } from '../util/Attribute'; /** * A menu button that opens a {@link SettingsMenu}. @@ -15,8 +14,8 @@ export class SettingsMenuButton extends MenuButton { override connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open settings menu'); + if (this.ariaLabel == null) { + this.ariaLabel = 'open settings menu'; } } diff --git a/src/components/TimeDisplay.ts b/src/components/TimeDisplay.ts index cd15cda8..c602af1e 100644 --- a/src/components/TimeDisplay.ts +++ b/src/components/TimeDisplay.ts @@ -28,8 +28,8 @@ export class TimeDisplay extends LitElement { if (!this.hasAttribute('role')) { this.setAttribute('role', 'progressbar'); } - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'playback time'); + if (this.ariaLabel == null) { + this.ariaLabel = 'playback time'; } if (!this.hasAttribute(Attribute.ARIA_LIVE)) { // Tell screen readers not to automatically read the time as it changes diff --git a/src/components/theolive/quality/BadNetworkModeButton.ts b/src/components/theolive/quality/BadNetworkModeButton.ts index 5670bbf0..1f9a4a6e 100644 --- a/src/components/theolive/quality/BadNetworkModeButton.ts +++ b/src/components/theolive/quality/BadNetworkModeButton.ts @@ -8,7 +8,6 @@ import settingsIcon from '../../../icons/settings.svg'; import warningIcon from '../../../icons/warning.svg'; import { stateReceiver } from '../../StateReceiverMixin'; import { MenuButton } from '../../MenuButton'; -import { Attribute } from '../../../util/Attribute'; /** * A menu button that opens a settings menu. @@ -29,8 +28,8 @@ export class BadNetworkModeButton extends MenuButton { override connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open settings menu'); + if (this.ariaLabel == null) { + this.ariaLabel = 'open settings menu'; } } From a9738514b660e2dae38a242e28856b41d2dfde5a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:23:11 +0200 Subject: [PATCH 12/54] Internationalize AirPlayButton and ChromecastButton --- src/components/AirPlayButton.ts | 4 +++- src/components/ChromecastButton.ts | 5 +++-- src/i18n/Locale.ts | 22 +++++++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/AirPlayButton.ts b/src/components/AirPlayButton.ts index 557c50dc..a80a579e 100644 --- a/src/components/AirPlayButton.ts +++ b/src/components/AirPlayButton.ts @@ -5,6 +5,7 @@ import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; import airPlayButtonHtml from './AirPlayButton.html'; import airPlayButtonCss from './AirPlayButton.css'; +import { getLocale } from '../i18n'; /** * A button to start and stop casting using AirPlay. @@ -36,7 +37,8 @@ export class AirPlayButton extends CastButton { } private _updateAriaLabel(): void { - this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? 'stop playing on AirPlay' : 'start playing on AirPlay'; + const locale = getLocale(this.lang); + this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? locale.airplayConnectedAria : locale.airplayAria; } protected override render() { diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index 7797bc4f..5d654446 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -5,6 +5,7 @@ import { CastButton } from './CastButton'; import chromecastButtonHtml from './ChromecastButton.html'; import chromecastButtonCss from './ChromecastButton.css'; import { customElement } from 'lit/decorators.js'; +import { getLocale } from '../i18n'; let chromecastButtonId = 0; @@ -43,8 +44,8 @@ export class ChromecastButton extends CastButton { } private _updateAriaLabel(): void { - this.ariaLabel = - this.castState === 'connecting' || this.castState === 'connected' ? 'stop casting to Chromecast' : 'start casting to Chromecast'; + const locale = getLocale(this.lang); + this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? locale.chromecastConnectedAria : locale.chromecastAria; } protected override render() { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 79e6b66c..c71562f5 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,5 +1,5 @@ import { durationFormatterForLocale } from './DurationFormatter'; -import type { PlayButton, TimeRange } from '../components'; +import type { AirPlayButton, ChromecastButton, PlayButton, TimeRange } from '../components'; export interface Locale { /** @@ -21,6 +21,22 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. */ seekAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link AirPlayButton}. + */ + airplayAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link AirPlayButton} when it is connected to AirPlay. + */ + airplayConnectedAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link ChromecastButton}. + */ + chromecastAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link ChromecastButton} when it is connected to Chromecast. + */ + chromecastConnectedAria: string; /** * Formats the given time duration as a human-readable string. * @@ -59,6 +75,10 @@ export const defaultLocale: Locale = { pauseAria: 'pause', replayAria: 'replay', seekAria: 'seek', + airplayAria: 'start playing on AirPlay', + airplayConnectedAria: 'stop playing on AirPlay', + chromecastAria: 'start casting to Chromecast', + chromecastConnectedAria: 'stop casting to Chromecast', formatDuration: durationFormatterForLocale(defaultLocaleName), formatRemainingDuration: (duration: string) => `${duration} remaining` }; From 758a71e7606c45127bbed23497bc8b31ac34b099 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:32:54 +0200 Subject: [PATCH 13/54] Fix BadNetworkModeButton --- src/components/theolive/quality/BadNetworkModeButton.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/theolive/quality/BadNetworkModeButton.ts b/src/components/theolive/quality/BadNetworkModeButton.ts index 1f9a4a6e..b748671e 100644 --- a/src/components/theolive/quality/BadNetworkModeButton.ts +++ b/src/components/theolive/quality/BadNetworkModeButton.ts @@ -8,11 +8,12 @@ import settingsIcon from '../../../icons/settings.svg'; import warningIcon from '../../../icons/warning.svg'; import { stateReceiver } from '../../StateReceiverMixin'; import { MenuButton } from '../../MenuButton'; +import type { BadNetworkModeMenu } from './BadNetworkModeMenu'; /** - * A menu button that opens a settings menu. + * A menu button that opens a {@link BadNetworkModeMenu}. * - * @attribute `menu` - The ID of the settings menu. + * @attribute `menu` - The ID of the bad network menu. */ @customElement('theolive-bad-network-button') @stateReceiver(['player']) @@ -29,7 +30,7 @@ export class BadNetworkModeButton extends MenuButton { super.connectedCallback(); if (this.ariaLabel == null) { - this.ariaLabel = 'open settings menu'; + this.ariaLabel = 'open bad network mode menu'; } } From eefd89a9c99db499d4e777c04969e34bfff30dac Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:38:08 +0200 Subject: [PATCH 14/54] Export THEOlive components --- src/components/index.ts | 1 + src/components/theolive/quality/index.ts | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 src/components/theolive/quality/index.ts diff --git a/src/components/index.ts b/src/components/index.ts index 8686faad..5f527b0f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -47,4 +47,5 @@ export * from './PreviewThumbnail'; export * from './LiveButton'; export { SlotContainer } from './SlotContainer'; export * from './ads/index'; +export * from './theolive/quality/index'; export { type StateReceiverElement, type StateReceiverPropertyMap, StateReceiverMixin, stateReceiver } from './StateReceiverMixin'; diff --git a/src/components/theolive/quality/index.ts b/src/components/theolive/quality/index.ts new file mode 100644 index 00000000..c3ee2e3e --- /dev/null +++ b/src/components/theolive/quality/index.ts @@ -0,0 +1,4 @@ +export * from './AutomaticQualitySelector'; +export * from './BadNetworkModeButton'; +export * from './BadNetworkModeMenu'; +export * from './BadNetworkModeSelector'; From 13c6d1adba683a0ab3ea3ece601728ae3110ab08 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:38:54 +0200 Subject: [PATCH 15/54] Internationalize menu buttons --- src/components/CloseMenuButton.ts | 21 ++++++----- src/components/LanguageMenuButton.ts | 21 ++++++----- src/components/PlaybackRateMenuButton.ts | 15 +++++--- src/components/SettingsMenuButton.ts | 15 +++++--- .../theolive/quality/BadNetworkModeButton.ts | 21 ++++++----- src/i18n/Locale.ts | 37 ++++++++++++++++++- 6 files changed, 90 insertions(+), 40 deletions(-) diff --git a/src/components/CloseMenuButton.ts b/src/components/CloseMenuButton.ts index 588cfee4..fa380a98 100644 --- a/src/components/CloseMenuButton.ts +++ b/src/components/CloseMenuButton.ts @@ -1,10 +1,11 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; import backIcon from '../icons/back.svg'; import { createCustomEvent } from '../util/EventUtils'; import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent'; +import { getLocale } from '../i18n'; /** * A button that closes its parent menu. @@ -13,14 +14,6 @@ import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent' */ @customElement('theoplayer-menu-close-button') export class CloseMenuButton extends Button { - override connectedCallback() { - super.connectedCallback(); - - if (this.ariaLabel == null) { - this.ariaLabel = 'close menu'; - } - } - protected override handleClick() { const event: CloseMenuEvent = createCustomEvent(CLOSE_MENU_EVENT, { bubbles: true, @@ -29,6 +22,16 @@ export class CloseMenuButton extends Button { this.dispatchEvent(event); } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.closeMenuAria; + } + protected override render(): HTMLTemplateResult { return html`${unsafeSVG(backIcon)}`; } diff --git a/src/components/LanguageMenuButton.ts b/src/components/LanguageMenuButton.ts index ef5bf04e..600fb35a 100644 --- a/src/components/LanguageMenuButton.ts +++ b/src/components/LanguageMenuButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; @@ -8,6 +8,7 @@ import type { ChromelessPlayer, MediaTrackList, TextTracksList } from 'theoplaye import { isNonForcedSubtitleTrack } from '../util/TrackUtils'; import { Attribute } from '../util/Attribute'; import { toggleAttribute } from '../util/CommonUtils'; +import { getLocale } from '../i18n'; const TRACK_EVENTS = ['addtrack', 'removetrack'] as const; @@ -23,14 +24,6 @@ export class LanguageMenuButton extends MenuButton { private _audioTrackList: MediaTrackList | undefined; private _textTrackList: TextTracksList | undefined; - override connectedCallback() { - super.connectedCallback(); - - if (this.ariaLabel == null) { - this.ariaLabel = 'open language menu'; - } - } - get player(): ChromelessPlayer | undefined { return this._player; } @@ -56,6 +49,16 @@ export class LanguageMenuButton extends MenuButton { toggleAttribute(this, Attribute.HIDDEN, !hasTracks); }; + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openLanguageMenuAria; + } + protected override render(): HTMLTemplateResult { return html`${unsafeSVG(languageIcon)}`; } diff --git a/src/components/PlaybackRateMenuButton.ts b/src/components/PlaybackRateMenuButton.ts index a11d3077..d94001b2 100644 --- a/src/components/PlaybackRateMenuButton.ts +++ b/src/components/PlaybackRateMenuButton.ts @@ -1,8 +1,9 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import speedIcon from '../icons/speed.svg'; +import { getLocale } from '../i18n'; /** * A menu button that opens a [playback rate menu]{@link PlaybackRateMenu}. @@ -11,12 +12,14 @@ import speedIcon from '../icons/speed.svg'; */ @customElement('theoplayer-playback-rate-menu-button') export class PlaybackRateMenuButton extends MenuButton { - override connectedCallback() { - super.connectedCallback(); + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } - if (this.ariaLabel == null) { - this.ariaLabel = 'open playback speed menu'; - } + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openPlaybackRateMenuAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/SettingsMenuButton.ts b/src/components/SettingsMenuButton.ts index 57d3425e..a5161067 100644 --- a/src/components/SettingsMenuButton.ts +++ b/src/components/SettingsMenuButton.ts @@ -1,8 +1,9 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import settingsIcon from '../icons/settings.svg'; +import { getLocale } from '../i18n'; /** * A menu button that opens a {@link SettingsMenu}. @@ -11,12 +12,14 @@ import settingsIcon from '../icons/settings.svg'; */ @customElement('theoplayer-settings-menu-button') export class SettingsMenuButton extends MenuButton { - override connectedCallback() { - super.connectedCallback(); + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } - if (this.ariaLabel == null) { - this.ariaLabel = 'open settings menu'; - } + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openSettingsMenuAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/theolive/quality/BadNetworkModeButton.ts b/src/components/theolive/quality/BadNetworkModeButton.ts index b748671e..affe9bcd 100644 --- a/src/components/theolive/quality/BadNetworkModeButton.ts +++ b/src/components/theolive/quality/BadNetworkModeButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; @@ -9,6 +9,7 @@ import warningIcon from '../../../icons/warning.svg'; import { stateReceiver } from '../../StateReceiverMixin'; import { MenuButton } from '../../MenuButton'; import type { BadNetworkModeMenu } from './BadNetworkModeMenu'; +import { getLocale } from '../../../i18n'; /** * A menu button that opens a {@link BadNetworkModeMenu}. @@ -26,14 +27,6 @@ export class BadNetworkModeButton extends MenuButton { @state() private accessor _inBadNetworkMode = false; - override connectedCallback() { - super.connectedCallback(); - - if (this.ariaLabel == null) { - this.ariaLabel = 'open bad network mode menu'; - } - } - private readonly handleEnterBadNetworkMode_ = () => { this._inBadNetworkMode = true; }; @@ -63,6 +56,16 @@ export class BadNetworkModeButton extends MenuButton { } } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openBadNetworkModeMenuAria; + } + protected override render(): HTMLTemplateResult { const warningStyles = { display: this._inBadNetworkMode ? '' : 'none' diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index c71562f5..44adf4c8 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,5 +1,15 @@ import { durationFormatterForLocale } from './DurationFormatter'; -import type { AirPlayButton, ChromecastButton, PlayButton, TimeRange } from '../components'; +import type { + AirPlayButton, + BadNetworkModeButton, + ChromecastButton, + CloseMenuButton, + LanguageMenuButton, + PlaybackRateMenuButton, + PlayButton, + SettingsMenuButton, + TimeRange +} from '../components'; export interface Locale { /** @@ -37,6 +47,26 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link ChromecastButton} when it is connected to Chromecast. */ chromecastConnectedAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link CloseMenuButton}. + */ + closeMenuAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link LanguageMenuButton}. + */ + openLanguageMenuAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link PlaybackRateMenuButton}. + */ + openPlaybackRateMenuAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link SettingsMenuButton}. + */ + openSettingsMenuAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link BadNetworkModeButton}. + */ + openBadNetworkModeMenuAria: string; /** * Formats the given time duration as a human-readable string. * @@ -79,6 +109,11 @@ export const defaultLocale: Locale = { airplayConnectedAria: 'stop playing on AirPlay', chromecastAria: 'start casting to Chromecast', chromecastConnectedAria: 'stop casting to Chromecast', + closeMenuAria: 'close menu', + openLanguageMenuAria: 'open language menu', + openPlaybackRateMenuAria: 'open playback speed menu', + openSettingsMenuAria: 'open settings menu', + openBadNetworkModeMenuAria: 'open bad network mode menu', formatDuration: durationFormatterForLocale(defaultLocaleName), formatRemainingDuration: (duration: string) => `${duration} remaining` }; From 167c6065533598ebec610ce4f63aa0adb8b998a3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:43:44 +0200 Subject: [PATCH 16/54] Internationalize LiveButton --- src/components/LiveButton.ts | 24 ++++++++++++++---------- src/i18n/Locale.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/LiveButton.ts b/src/components/LiveButton.ts index 2330c7bd..c866deb3 100644 --- a/src/components/LiveButton.ts +++ b/src/components/LiveButton.ts @@ -1,4 +1,4 @@ -import { html, type HTMLTemplateResult } from 'lit'; +import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; @@ -8,6 +8,7 @@ import liveIcon from '../icons/live.svg'; import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; import type { StreamType } from '../util/StreamType'; +import { getLocale } from '../i18n'; const PAUSED_EVENTS = ['play', 'pause', 'playing', 'emptied'] as const; const LIVE_EVENTS = ['seeking', 'seeked', 'timeupdate', 'durationchange', 'emptied'] as const; @@ -29,14 +30,6 @@ export class LiveButton extends Button { private _player: ChromelessPlayer | undefined; private _liveThreshold: number = DEFAULT_LIVE_THRESHOLD; - connectedCallback() { - super.connectedCallback(); - - if (this.ariaLabel == null) { - this.ariaLabel = 'seek to live'; - } - } - /** * Whether the player is paused. */ @@ -113,9 +106,20 @@ export class LiveButton extends Button { } } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.seekToLiveAria; + } + protected override render(): HTMLTemplateResult { + const locale = getLocale(this.lang); return html`${unsafeSVG(liveIcon)} LIVE`; + > ${locale.live}`; } } diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 44adf4c8..ffeaad2d 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -5,6 +5,7 @@ import type { ChromecastButton, CloseMenuButton, LanguageMenuButton, + LiveButton, PlaybackRateMenuButton, PlayButton, SettingsMenuButton, @@ -31,6 +32,14 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. */ seekAria: string; + /** + * The text on a {@link LiveButton}, e.g. "LIVE". + */ + live: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link LiveButton}. + */ + seekToLiveAria: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link AirPlayButton}. */ @@ -105,6 +114,8 @@ export const defaultLocale: Locale = { pauseAria: 'pause', replayAria: 'replay', seekAria: 'seek', + live: 'LIVE', + seekToLiveAria: 'seek to live', airplayAria: 'start playing on AirPlay', airplayConnectedAria: 'stop playing on AirPlay', chromecastAria: 'start casting to Chromecast', From af09454f7e1e92a9dc6ea4c11dd037432cb95842 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:54:23 +0200 Subject: [PATCH 17/54] Internationalize FullscreenButton --- src/components/FullscreenButton.ts | 4 +++- src/i18n/Locale.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index 38eb1f9b..f4c989d7 100644 --- a/src/components/FullscreenButton.ts +++ b/src/components/FullscreenButton.ts @@ -10,6 +10,7 @@ import { createCustomEvent } from '../util/EventUtils'; import { ENTER_FULLSCREEN_EVENT, type EnterFullscreenEvent } from '../events/EnterFullscreenEvent'; import { EXIT_FULLSCREEN_EVENT, type ExitFullscreenEvent } from '../events/ExitFullscreenEvent'; import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; /** * A button that toggles fullscreen. @@ -52,7 +53,8 @@ export class FullscreenButton extends Button { } private _updateAriaLabel(): void { - this.ariaLabel = this.fullscreen ? 'exit fullscreen' : 'enter fullscreen'; + const locale = getLocale(this.lang); + this.ariaLabel = this.fullscreen ? locale.fullscreenExitAria : locale.fullscreenAria; } protected override render(): HTMLTemplateResult { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index ffeaad2d..30f80fbc 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -4,6 +4,7 @@ import type { BadNetworkModeButton, ChromecastButton, CloseMenuButton, + FullscreenButton, LanguageMenuButton, LiveButton, PlaybackRateMenuButton, @@ -40,6 +41,14 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link LiveButton}. */ seekToLiveAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link FullscreenButton}. + */ + fullscreenAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link FullscreenButton} when in fullscreen mode. + */ + fullscreenExitAria: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link AirPlayButton}. */ @@ -116,6 +125,8 @@ export const defaultLocale: Locale = { seekAria: 'seek', live: 'LIVE', seekToLiveAria: 'seek to live', + fullscreenAria: 'enter fullscreen', + fullscreenExitAria: 'exit fullscreen', airplayAria: 'start playing on AirPlay', airplayConnectedAria: 'stop playing on AirPlay', chromecastAria: 'start casting to Chromecast', From 361fe86e38be180be1bac555a4c034702d6babfc Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:54:41 +0200 Subject: [PATCH 18/54] Internationalize MuteButton --- src/components/MuteButton.ts | 4 +++- src/i18n/Locale.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/MuteButton.ts b/src/components/MuteButton.ts index ed429820..bda964af 100644 --- a/src/components/MuteButton.ts +++ b/src/components/MuteButton.ts @@ -9,6 +9,7 @@ import lowIcon from '../icons/volume-low.svg'; import highIcon from '../icons/volume-high.svg'; import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; export type VolumeLevel = 'off' | 'low' | 'high'; @@ -86,7 +87,8 @@ export class MuteButton extends Button { } private _updateAriaLabel(): void { - this.ariaLabel = this.volumeLevel === 'off' ? 'unmute' : 'mute'; + const locale = getLocale(this.lang); + this.ariaLabel = this.volumeLevel === 'off' ? locale.unmuteAria : locale.muteAria; } protected override render(): HTMLTemplateResult { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 30f80fbc..7fc4ae91 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -7,6 +7,7 @@ import type { FullscreenButton, LanguageMenuButton, LiveButton, + MuteButton, PlaybackRateMenuButton, PlayButton, SettingsMenuButton, @@ -29,6 +30,14 @@ export interface Locale { * i.e. when the player is paused at the end of the stream. */ replayAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link MuteButton} when it is showing a "mute" button. + */ + muteAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link MuteButton} when it is showing an "unmute" button. + */ + unmuteAria: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. */ @@ -122,6 +131,8 @@ export const defaultLocale: Locale = { playAria: 'play', pauseAria: 'pause', replayAria: 'replay', + muteAria: 'mute', + unmuteAria: 'unmute', seekAria: 'seek', live: 'LIVE', seekToLiveAria: 'seek to live', From 57304e57ddd574e14214402e14ac6b9912a4eb7e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 12 May 2026 18:55:00 +0200 Subject: [PATCH 19/54] Internationalize SeekButton --- src/components/SeekButton.ts | 10 ++++------ src/i18n/Locale.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index c94d50cd..62f76cd1 100644 --- a/src/components/SeekButton.ts +++ b/src/components/SeekButton.ts @@ -7,6 +7,7 @@ import seekForwardIcon from '../icons/seek-forward.svg'; import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; +import { getLocale } from '../i18n'; const DEFAULT_SEEK_OFFSET = 10; @@ -22,11 +23,6 @@ export class SeekButton extends Button { private _player: ChromelessPlayer | undefined; - override connectedCallback() { - super.connectedCallback(); - this._updateAriaLabel(); - } - /** * The offset (in seconds) by which to seek forward (if positive) or backward (if negative). */ @@ -59,8 +55,10 @@ export class SeekButton extends Button { } private _updateAriaLabel(): void { + const locale = getLocale(this.lang); const seekOffset = this.seekOffset; - this.ariaLabel = seekOffset >= 0 ? `seek forward by ${seekOffset} seconds` : `seek backward by ${-seekOffset} seconds`; + const duration = locale.formatDuration({ hours: 0, minutes: 0, seconds: Math.abs(seekOffset) }); + this.ariaLabel = seekOffset >= 0 ? locale.seekForwardAria(duration) : locale.seekBackwardAria(duration); } protected override render() { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 7fc4ae91..2ce3615a 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -10,6 +10,7 @@ import type { MuteButton, PlaybackRateMenuButton, PlayButton, + SeekButton, SettingsMenuButton, TimeRange } from '../components'; @@ -42,6 +43,18 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. */ seekAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link SeekButton} with a positive {@link SeekButton.seekOffset}. + * + * @param offset An offset that was formatted with {@link formatDuration}. + */ + seekForwardAria(offset: string): string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link SeekButton} with a negative {@link SeekButton.seekOffset}. + * + * @param offset An offset that was formatted with {@link formatDuration}. + */ + seekBackwardAria(offset: string): string; /** * The text on a {@link LiveButton}, e.g. "LIVE". */ @@ -134,6 +147,8 @@ export const defaultLocale: Locale = { muteAria: 'mute', unmuteAria: 'unmute', seekAria: 'seek', + seekForwardAria: (offset) => `seek forward by ${offset}`, + seekBackwardAria: (offset) => `seek backward by ${offset}`, live: 'LIVE', seekToLiveAria: 'seek to live', fullscreenAria: 'enter fullscreen', From bcd2737daaa22ed2321d4354c77f408c52728c3b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 19:31:42 +0200 Subject: [PATCH 20/54] Internationalize ad components --- src/components/ads/AdClickThroughButton.ts | 4 ++- src/components/ads/AdCountdown.ts | 5 ++- src/components/ads/AdDisplay.ts | 6 ++-- src/components/ads/AdSkipButton.ts | 7 ++-- src/i18n/Locale.ts | 41 ++++++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/components/ads/AdClickThroughButton.ts b/src/components/ads/AdClickThroughButton.ts index 91697265..5cf8065b 100644 --- a/src/components/ads/AdClickThroughButton.ts +++ b/src/components/ads/AdClickThroughButton.ts @@ -6,6 +6,7 @@ import { Attribute } from '../../util/Attribute'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { arrayFind } from '../../util/CommonUtils'; import { isLinearAd } from '../../util/AdUtils'; +import { getLocale } from '../../i18n'; const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as const; @@ -100,7 +101,8 @@ export class AdClickThroughButton extends LinkButton { } protected override renderLinkContent(): HTMLTemplateResult { - return html`Visit Advertiser`; + const locale = getLocale(this.lang); + return html`${locale.adClickThroughText}`; } } diff --git a/src/components/ads/AdCountdown.ts b/src/components/ads/AdCountdown.ts index 210f6b02..6329b39f 100644 --- a/src/components/ads/AdCountdown.ts +++ b/src/components/ads/AdCountdown.ts @@ -4,6 +4,7 @@ import textDisplayCss from '../TextDisplay.css'; import adCountdownCss from './AdCountdown.css'; import { stateReceiver } from '../StateReceiverMixin'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; +import { getLocale } from '../../i18n'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak'] as const; @@ -61,7 +62,9 @@ export class AdCountdown extends LitElement { }; protected override render(): HTMLTemplateResult { - return html`Content will resume in ${this._maxRemainingDuration}s`; + const locale = getLocale(this.lang); + const remainingDuration = locale.formatDuration({ hours: 0, minutes: 0, seconds: this._maxRemainingDuration }); + return html`${locale.adCountdownText(remainingDuration)}`; } } diff --git a/src/components/ads/AdDisplay.ts b/src/components/ads/AdDisplay.ts index 21b64e3a..b1c74bb2 100644 --- a/src/components/ads/AdDisplay.ts +++ b/src/components/ads/AdDisplay.ts @@ -6,6 +6,7 @@ import { stateReceiver } from '../StateReceiverMixin'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { arrayFind } from '../../util/CommonUtils'; import { isLinearAd } from '../../util/AdUtils'; +import { getLocale } from '../../i18n'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak', 'adbegin', 'adend', 'adskip', 'addad', 'updatead'] as const; @@ -51,6 +52,7 @@ export class AdDisplay extends LitElement { } private readonly _updateFromPlayer = () => { + const locale = getLocale(this.lang); const ads = this._player?.ads; const linearAds = (ads?.currentAdBreak?.ads ?? []).filter(isLinearAd); if (ads === undefined || !ads.playing || linearAds.length === 0) { @@ -64,13 +66,13 @@ export class AdDisplay extends LitElement { if (currentLinearAd) { const currentAdIndex = linearAds.indexOf(currentLinearAd); if (currentAdIndex >= 0) { - this._text = `Ad ${currentAdIndex + 1} of ${linearAds.length}`; + this._text = locale.adBreakText(currentAdIndex + 1, linearAds.length); this.style.display = ''; return; } } } - this._text = 'Ad'; + this._text = locale.adText; this.style.display = ''; }; diff --git a/src/components/ads/AdSkipButton.ts b/src/components/ads/AdSkipButton.ts index 0ce17479..8aa82b88 100644 --- a/src/components/ads/AdSkipButton.ts +++ b/src/components/ads/AdSkipButton.ts @@ -10,6 +10,7 @@ import { Attribute } from '../../util/Attribute'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { arrayFind } from '../../util/CommonUtils'; import { isLinearAd } from '../../util/AdUtils'; +import { getLocale } from '../../i18n'; const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as const; @@ -123,6 +124,7 @@ export class AdSkipButton extends Button { }; protected override render(): HTMLTemplateResult { + const locale = getLocale(this.lang); const countdownStyles = { visibility: this._showCountdown ? 'visible' : 'hidden' }; @@ -130,9 +132,10 @@ export class AdSkipButton extends Button { visibility: this._showCountdown ? 'hidden' : 'visible', pointerEvents: this._showCountdown ? 'none' : '' }; - return html`Skip in ${this._timeToSkip} seconds + const timeToSkip = locale.formatDuration({ hours: 0, minutes: 0, seconds: this._timeToSkip }); + return html`${locale.adSkipCountdownText(timeToSkip)} - Skip Ad + ${locale.adSkipButtonText} ${unsafeSVG(skipNextIcon)} `; } diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 2ce3615a..45220a2f 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,5 +1,9 @@ import { durationFormatterForLocale } from './DurationFormatter'; import type { + AdClickThroughButton, + AdCountdown, + AdDisplay, + AdSkipButton, AirPlayButton, BadNetworkModeButton, ChromecastButton, @@ -87,6 +91,37 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link ChromecastButton} when it is connected to Chromecast. */ chromecastConnectedAria: string; + /** + * The text on an {@link AdDisplay}, e.g. "Ad". + */ + adText: string; + /** + * The text on an {@link AdDisplay} when playing multiple ads in an ad break, e.g. "Ad X of Y". + * + * @param currentAd The number of the currently playing ad. + * @param totalAds The total number of ads in the current ad break. + */ + adBreakText(currentAd: number, totalAds: number): string; + /** + * The text on an {@link AdClickThroughButton}, e.g. "Visit Advertiser". + */ + adClickThroughText: string; + /** + * The text on an {@link AdCountdown}, e.g. "Content will resume in X seconds". + * + * @param remainingDuration The remaining time until the content can be resumed, after being formatted with {@link formatDuration}. + */ + adCountdownText(remainingDuration: string): string; + /** + * The text on an {@link AdSkipButton}, e.g. "Skip Ad". + */ + adSkipButtonText: string; + /** + * The text on an {@link AdSkipButton} when it is showing a countdown. + * + * @param remainingDuration The remaining time until the ad can be skipped, after being formatted with {@link formatDuration}. + */ + adSkipCountdownText(remainingDuration: string): string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link CloseMenuButton}. */ @@ -157,6 +192,12 @@ export const defaultLocale: Locale = { airplayConnectedAria: 'stop playing on AirPlay', chromecastAria: 'start casting to Chromecast', chromecastConnectedAria: 'stop casting to Chromecast', + adText: 'Ad', + adBreakText: (currentAd: number, totalAds: number) => `Ad ${currentAd} of ${totalAds}`, + adClickThroughText: 'Visit Advertiser', + adCountdownText: (remainingDuration: string) => `Content will resume in ${remainingDuration}`, + adSkipButtonText: 'Skip Ad', + adSkipCountdownText: (remainingDuration: string) => `Skip in ${remainingDuration}`, closeMenuAria: 'close menu', openLanguageMenuAria: 'open language menu', openPlaybackRateMenuAria: 'open playback speed menu', From 6c14536a65c71428c6d4a951680a8bb86df5fa02 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 19:39:18 +0200 Subject: [PATCH 21/54] Use narrow duration format for ad components --- src/components/ads/AdCountdown.ts | 2 +- src/components/ads/AdSkipButton.ts | 2 +- src/i18n/DurationFormatter.ts | 14 ++++++++++---- src/i18n/Locale.ts | 19 ++++++++++++++++--- src/i18n/LocaleRegistry.ts | 3 ++- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/ads/AdCountdown.ts b/src/components/ads/AdCountdown.ts index 6329b39f..4bc85482 100644 --- a/src/components/ads/AdCountdown.ts +++ b/src/components/ads/AdCountdown.ts @@ -63,7 +63,7 @@ export class AdCountdown extends LitElement { protected override render(): HTMLTemplateResult { const locale = getLocale(this.lang); - const remainingDuration = locale.formatDuration({ hours: 0, minutes: 0, seconds: this._maxRemainingDuration }); + const remainingDuration = locale.formatNarrowDuration({ hours: 0, minutes: 0, seconds: this._maxRemainingDuration }); return html`${locale.adCountdownText(remainingDuration)}`; } } diff --git a/src/components/ads/AdSkipButton.ts b/src/components/ads/AdSkipButton.ts index 8aa82b88..75d2a05d 100644 --- a/src/components/ads/AdSkipButton.ts +++ b/src/components/ads/AdSkipButton.ts @@ -132,7 +132,7 @@ export class AdSkipButton extends Button { visibility: this._showCountdown ? 'hidden' : 'visible', pointerEvents: this._showCountdown ? 'none' : '' }; - const timeToSkip = locale.formatDuration({ hours: 0, minutes: 0, seconds: this._timeToSkip }); + const timeToSkip = locale.formatNarrowDuration({ hours: 0, minutes: 0, seconds: this._timeToSkip }); return html`${locale.adSkipCountdownText(timeToSkip)} ${locale.adSkipButtonText} diff --git a/src/i18n/DurationFormatter.ts b/src/i18n/DurationFormatter.ts index 1acc817d..aac69e09 100644 --- a/src/i18n/DurationFormatter.ts +++ b/src/i18n/DurationFormatter.ts @@ -1,17 +1,17 @@ import type { Duration, DurationFormatter } from './Locale'; -export function durationFormatterForLocale(locale: string): DurationFormatter { +export function durationFormatterForLocale(locale: string, style: 'long' | 'narrow'): DurationFormatter { try { // Use Intl.DurationFormat (if supported). - const formatter = new Intl.DurationFormat(locale, { style: 'long' }); - const zeroFormat = new Intl.DurationFormat(locale, { style: 'long', secondsDisplay: 'always' }).format({ seconds: 0 }); + const formatter = new Intl.DurationFormat(locale, { style }); + const zeroFormat = new Intl.DurationFormat(locale, { style, secondsDisplay: 'always' }).format({ seconds: 0 }); return (duration) => { // If duration is zero, shows "0 seconds". return duration.hours === 0 && duration.minutes === 0 && duration.seconds === 0 ? zeroFormat : formatter.format(duration); }; } catch { // Fall back to English. - return defaultFormatDuration; + return style === 'narrow' ? defaultNarrowFormatDuration : defaultFormatDuration; } } @@ -24,3 +24,9 @@ export function defaultFormatDuration({ hours, minutes, seconds }: Duration): st .filter((part) => part !== '') .join(', '); } + +export function defaultNarrowFormatDuration({ hours, minutes, seconds }: Duration): string { + return [hours === 0 ? '' : `${hours}h`, minutes === 0 ? '' : `${minutes}m`, seconds === 0 && (hours > 0 || minutes > 0) ? '' : `${seconds}s`] + .filter((part) => part !== '') + .join(' '); +} diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 45220a2f..8ed7c441 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -109,7 +109,7 @@ export interface Locale { /** * The text on an {@link AdCountdown}, e.g. "Content will resume in X seconds". * - * @param remainingDuration The remaining time until the content can be resumed, after being formatted with {@link formatDuration}. + * @param remainingDuration The remaining time until the content can be resumed, after being formatted with {@link formatNarrowDuration}. */ adCountdownText(remainingDuration: string): string; /** @@ -119,7 +119,7 @@ export interface Locale { /** * The text on an {@link AdSkipButton} when it is showing a countdown. * - * @param remainingDuration The remaining time until the ad can be skipped, after being formatted with {@link formatDuration}. + * @param remainingDuration The remaining time until the ad can be skipped, after being formatted with {@link formatNarrowDuration}. */ adSkipCountdownText(remainingDuration: string): string; /** @@ -154,6 +154,18 @@ export interface Locale { * @param duration A duration, compatible with {@link Intl.DurationFormat.format}. */ formatDuration(duration: Duration): string; + /** + * Formats the given time duration as a narrow human-readable string. + * + * This is optional. If not provided, a default {@link Intl.DurationFormat} with the {@link Intl.DurationFormatStyle | `"narrow"` style} is used. + * + * Examples: + * - `{ seconds: 5 }` → "5s" + * - `{ minutes: 2, seconds: 10 }` → "2m 10s" + * + * @param duration A duration, compatible with {@link Intl.DurationFormat.format}. + */ + formatNarrowDuration(duration: Duration): string; /** * Formats the given remaining time duration as a human-readable string. * @@ -203,6 +215,7 @@ export const defaultLocale: Locale = { openPlaybackRateMenuAria: 'open playback speed menu', openSettingsMenuAria: 'open settings menu', openBadNetworkModeMenuAria: 'open bad network mode menu', - formatDuration: durationFormatterForLocale(defaultLocaleName), + formatDuration: durationFormatterForLocale(defaultLocaleName, 'long'), + formatNarrowDuration: durationFormatterForLocale(defaultLocaleName, 'narrow'), formatRemainingDuration: (duration: string) => `${duration} remaining` }; diff --git a/src/i18n/LocaleRegistry.ts b/src/i18n/LocaleRegistry.ts index d5abad60..930b5f2e 100644 --- a/src/i18n/LocaleRegistry.ts +++ b/src/i18n/LocaleRegistry.ts @@ -21,6 +21,7 @@ export function addLocale(name: string, locale: Partial) { localesByName[name] = { ...defaultLocale, ...locale, - formatDuration: locale.formatDuration ?? durationFormatterForLocale(name) + formatDuration: locale.formatDuration ?? durationFormatterForLocale(name, 'long'), + formatNarrowDuration: locale.formatNarrowDuration ?? durationFormatterForLocale(name, 'narrow') }; } From 46e0c2067939d12aa81d93af4b5641e5846e7b6e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 19:45:28 +0200 Subject: [PATCH 22/54] Extract `toDuration` helper --- src/components/SeekButton.ts | 3 ++- src/components/ads/AdCountdown.ts | 3 ++- src/components/ads/AdSkipButton.ts | 3 ++- src/util/TimeUtils.ts | 18 ++++++++++-------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index 62f76cd1..3275b217 100644 --- a/src/components/SeekButton.ts +++ b/src/components/SeekButton.ts @@ -8,6 +8,7 @@ import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { getLocale } from '../i18n'; +import { toDuration } from '../util/TimeUtils'; const DEFAULT_SEEK_OFFSET = 10; @@ -57,7 +58,7 @@ export class SeekButton extends Button { private _updateAriaLabel(): void { const locale = getLocale(this.lang); const seekOffset = this.seekOffset; - const duration = locale.formatDuration({ hours: 0, minutes: 0, seconds: Math.abs(seekOffset) }); + const duration = locale.formatDuration(toDuration(Math.abs(seekOffset))); this.ariaLabel = seekOffset >= 0 ? locale.seekForwardAria(duration) : locale.seekBackwardAria(duration); } diff --git a/src/components/ads/AdCountdown.ts b/src/components/ads/AdCountdown.ts index 4bc85482..28915867 100644 --- a/src/components/ads/AdCountdown.ts +++ b/src/components/ads/AdCountdown.ts @@ -5,6 +5,7 @@ import adCountdownCss from './AdCountdown.css'; import { stateReceiver } from '../StateReceiverMixin'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { getLocale } from '../../i18n'; +import { toDuration } from '../../util/TimeUtils'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak'] as const; @@ -63,7 +64,7 @@ export class AdCountdown extends LitElement { protected override render(): HTMLTemplateResult { const locale = getLocale(this.lang); - const remainingDuration = locale.formatNarrowDuration({ hours: 0, minutes: 0, seconds: this._maxRemainingDuration }); + const remainingDuration = locale.formatNarrowDuration(toDuration(this._maxRemainingDuration)); return html`${locale.adCountdownText(remainingDuration)}`; } } diff --git a/src/components/ads/AdSkipButton.ts b/src/components/ads/AdSkipButton.ts index 75d2a05d..da98da37 100644 --- a/src/components/ads/AdSkipButton.ts +++ b/src/components/ads/AdSkipButton.ts @@ -11,6 +11,7 @@ import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { arrayFind } from '../../util/CommonUtils'; import { isLinearAd } from '../../util/AdUtils'; import { getLocale } from '../../i18n'; +import { toDuration } from '../../util/TimeUtils'; const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as const; @@ -132,7 +133,7 @@ export class AdSkipButton extends Button { visibility: this._showCountdown ? 'hidden' : 'visible', pointerEvents: this._showCountdown ? 'none' : '' }; - const timeToSkip = locale.formatNarrowDuration({ hours: 0, minutes: 0, seconds: this._timeToSkip }); + const timeToSkip = locale.formatNarrowDuration(toDuration(this._timeToSkip)); return html`${locale.adSkipCountdownText(timeToSkip)} ${locale.adSkipButtonText} diff --git a/src/util/TimeUtils.ts b/src/util/TimeUtils.ts index 158782b4..74d6874d 100644 --- a/src/util/TimeUtils.ts +++ b/src/util/TimeUtils.ts @@ -1,4 +1,4 @@ -import type { Locale } from '../i18n'; +import { type Duration, type Locale } from '../i18n'; function isValidNumber(x: number): boolean { return !isNaN(x) && isFinite(x); @@ -35,16 +35,18 @@ export function formatTime(time: number, guide: number = 0, preferNegative?: boo return `${negative ? '-' : ''}${timePhrase}`; } +export function toDuration(seconds: number): Duration { + return { + hours: Math.floor(seconds / 3600), + minutes: Math.floor(seconds / 60) % 60, + seconds: Math.floor(seconds % 60) + }; +} + export function formatAsTimePhrase(locale: Locale, time: number, preferNegative?: boolean): string { if (!isValidNumber(time)) return ''; const negative = time < 0 || (preferNegative && time === 0); - time = Math.abs(time); - - const seconds = Math.floor(time % 60); - const minutes = Math.floor(time / 60) % 60; - const hours = Math.floor(time / 3600); - - const duration = locale.formatDuration({ hours, minutes, seconds }); + const duration = locale.formatDuration(toDuration(Math.abs(time))); // If the time was negative, assume it represents some remaining amount of time/"count down". return negative ? locale.formatRemainingDuration(duration) : duration; From 029174d9410f52942ed79cfbc1b99ae9d356d3e1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 19:51:52 +0200 Subject: [PATCH 23/54] Rework `AdDisplay` state --- src/components/ads/AdDisplay.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/ads/AdDisplay.ts b/src/components/ads/AdDisplay.ts index b1c74bb2..b741476b 100644 --- a/src/components/ads/AdDisplay.ts +++ b/src/components/ads/AdDisplay.ts @@ -28,7 +28,10 @@ export class AdDisplay extends LitElement { private _ads: Ads | undefined; @state() - private accessor _text: string = ''; + private accessor _currentAd: number = 0; + + @state() + private accessor _totalAds: number = 0; connectedCallback(): void { super.connectedCallback(); @@ -52,32 +55,34 @@ export class AdDisplay extends LitElement { } private readonly _updateFromPlayer = () => { - const locale = getLocale(this.lang); const ads = this._player?.ads; const linearAds = (ads?.currentAdBreak?.ads ?? []).filter(isLinearAd); if (ads === undefined || !ads.playing || linearAds.length === 0) { - this._text = ''; + this._currentAd = 0; + this._totalAds = 0; this.style.display = 'none'; return; } + let currentAd = 0; if (linearAds.length > 1) { const currentAds = this._player!.ads!.currentAds || []; const currentLinearAd = arrayFind(currentAds, isLinearAd); if (currentLinearAd) { const currentAdIndex = linearAds.indexOf(currentLinearAd); if (currentAdIndex >= 0) { - this._text = locale.adBreakText(currentAdIndex + 1, linearAds.length); - this.style.display = ''; - return; + currentAd = currentAdIndex + 1; } } } - this._text = locale.adText; + this._currentAd = currentAd; + this._totalAds = linearAds.length; this.style.display = ''; }; protected override render(): HTMLTemplateResult { - return html`${this._text}`; + const locale = getLocale(this.lang); + const text = this._totalAds > 1 ? locale.adBreakText(this._currentAd, this._totalAds) : locale.adText; + return html`${text}`; } } From 82f7f1edab2b6db4c16712448450c14efbc9ddb7 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:00:11 +0200 Subject: [PATCH 24/54] Push language down to all localized components --- src/components/AirPlayButton.ts | 8 ++++++-- src/components/ChromecastButton.ts | 8 ++++++-- src/components/CloseMenuButton.ts | 8 +++++++- src/components/FullscreenButton.ts | 5 ++++- src/components/LanguageMenuButton.ts | 5 ++++- src/components/LiveButton.ts | 5 ++++- src/components/MuteButton.ts | 5 ++++- src/components/PlaybackRateMenuButton.ts | 8 +++++++- src/components/SeekButton.ts | 5 ++++- src/components/SettingsMenuButton.ts | 8 +++++++- src/components/TimeRange.ts | 2 +- src/components/ads/AdClickThroughButton.ts | 5 ++++- src/components/ads/AdCountdown.ts | 6 +++++- src/components/ads/AdDisplay.ts | 6 +++++- src/components/ads/AdSkipButton.ts | 5 ++++- src/components/theolive/quality/BadNetworkModeButton.ts | 6 +++++- 16 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/components/AirPlayButton.ts b/src/components/AirPlayButton.ts index a80a579e..cfbd7112 100644 --- a/src/components/AirPlayButton.ts +++ b/src/components/AirPlayButton.ts @@ -1,17 +1,18 @@ import { type PropertyValues } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; import airPlayButtonHtml from './AirPlayButton.html'; import airPlayButtonCss from './AirPlayButton.css'; import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; /** * A button to start and stop casting using AirPlay. */ @customElement('theoplayer-airplay-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class AirPlayButton extends CastButton { static styles = [...CastButton.styles, airPlayButtonCss]; @@ -31,6 +32,9 @@ export class AirPlayButton extends CastButton { this.castApi = player?.cast?.airplay; } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); this._updateAriaLabel(); diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index 5d654446..96bd0977 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -4,8 +4,9 @@ import { stateReceiver } from './StateReceiverMixin'; import { CastButton } from './CastButton'; import chromecastButtonHtml from './ChromecastButton.html'; import chromecastButtonCss from './ChromecastButton.css'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; let chromecastButtonId = 0; @@ -13,7 +14,7 @@ let chromecastButtonId = 0; * A button to start and stop casting using Chromecast. */ @customElement('theoplayer-chromecast-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class ChromecastButton extends CastButton { static styles = [...CastButton.styles, chromecastButtonCss]; @@ -38,6 +39,9 @@ export class ChromecastButton extends CastButton { this.castApi = player?.cast?.chromecast; } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); this._updateAriaLabel(); diff --git a/src/components/CloseMenuButton.ts b/src/components/CloseMenuButton.ts index fa380a98..2d97efe5 100644 --- a/src/components/CloseMenuButton.ts +++ b/src/components/CloseMenuButton.ts @@ -1,11 +1,13 @@ import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { Button } from './Button'; import backIcon from '../icons/back.svg'; import { createCustomEvent } from '../util/EventUtils'; import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent'; import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; +import { stateReceiver } from './StateReceiverMixin'; /** * A button that closes its parent menu. @@ -13,6 +15,7 @@ import { getLocale } from '../i18n'; * This button must be placed inside a {@link Menu | ``}. */ @customElement('theoplayer-menu-close-button') +@stateReceiver(['lang']) export class CloseMenuButton extends Button { protected override handleClick() { const event: CloseMenuEvent = createCustomEvent(CLOSE_MENU_EVENT, { @@ -22,6 +25,9 @@ export class CloseMenuButton extends Button { this.dispatchEvent(event); } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); this._updateAriaLabel(); diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index f4c989d7..d5d6fdba 100644 --- a/src/components/FullscreenButton.ts +++ b/src/components/FullscreenButton.ts @@ -16,7 +16,7 @@ import { getLocale } from '../i18n'; * A button that toggles fullscreen. */ @customElement('theoplayer-fullscreen-button') -@stateReceiver(['fullscreen']) +@stateReceiver(['fullscreen', 'lang']) export class FullscreenButton extends Button { static styles = [...Button.styles, fullscreenButtonCss]; @@ -31,6 +31,9 @@ export class FullscreenButton extends Button { @property({ reflect: true, type: Boolean, attribute: Attribute.FULLSCREEN }) accessor fullscreen: boolean = false; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + protected override handleClick(): void { if (!this.fullscreen) { const event: EnterFullscreenEvent = createCustomEvent(ENTER_FULLSCREEN_EVENT, { diff --git a/src/components/LanguageMenuButton.ts b/src/components/LanguageMenuButton.ts index 600fb35a..310ffd60 100644 --- a/src/components/LanguageMenuButton.ts +++ b/src/components/LanguageMenuButton.ts @@ -18,7 +18,7 @@ const TRACK_EVENTS = ['addtrack', 'removetrack'] as const; * When there are no alternative audio languages or subtitles, this button automatically hides itself. */ @customElement('theoplayer-language-menu-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class LanguageMenuButton extends MenuButton { private _player: ChromelessPlayer | undefined; private _audioTrackList: MediaTrackList | undefined; @@ -43,6 +43,9 @@ export class LanguageMenuButton extends MenuButton { this._textTrackList?.addEventListener(TRACK_EVENTS, this._updateTracks); } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + private readonly _updateTracks = (): void => { const hasTracks = this._player !== undefined && (this._player.audioTracks.length >= 2 || this._player.textTracks.some(isNonForcedSubtitleTrack)); diff --git a/src/components/LiveButton.ts b/src/components/LiveButton.ts index c866deb3..64a28461 100644 --- a/src/components/LiveButton.ts +++ b/src/components/LiveButton.ts @@ -23,7 +23,7 @@ const DEFAULT_LIVE_THRESHOLD = 10; * @cssproperty `--theoplayer-live-button-active-color` - The color of the live indicator when playing at the live point. Defaults to `red`. */ @customElement('theoplayer-live-button') -@stateReceiver(['player', 'streamType']) +@stateReceiver(['player', 'streamType', 'lang']) export class LiveButton extends Button { static styles = [...Button.styles, liveButtonCss]; @@ -64,6 +64,9 @@ export class LiveButton extends Button { @property({ reflect: true, type: Boolean, attribute: Attribute.LIVE }) accessor live: boolean = false; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } diff --git a/src/components/MuteButton.ts b/src/components/MuteButton.ts index bda964af..de95aad4 100644 --- a/src/components/MuteButton.ts +++ b/src/components/MuteButton.ts @@ -19,7 +19,7 @@ const PLAYER_EVENTS = ['volumechange'] as const; * A button that toggles whether audio is muted or not. */ @customElement('theoplayer-mute-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class MuteButton extends Button { static styles = [...Button.styles, muteButtonCss]; @@ -39,6 +39,9 @@ export class MuteButton extends Button { @property({ reflect: true, state: true, type: String, attribute: Attribute.VOLUME_LEVEL }) accessor volumeLevel: VolumeLevel = 'off'; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } diff --git a/src/components/PlaybackRateMenuButton.ts b/src/components/PlaybackRateMenuButton.ts index d94001b2..89ba58ac 100644 --- a/src/components/PlaybackRateMenuButton.ts +++ b/src/components/PlaybackRateMenuButton.ts @@ -1,9 +1,11 @@ import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import speedIcon from '../icons/speed.svg'; import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; /** * A menu button that opens a [playback rate menu]{@link PlaybackRateMenu}. @@ -11,7 +13,11 @@ import { getLocale } from '../i18n'; * @attribute menu - The ID of the playback rate menu. */ @customElement('theoplayer-playback-rate-menu-button') +@stateReceiver(['lang']) export class PlaybackRateMenuButton extends MenuButton { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); this._updateAriaLabel(); diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index 3275b217..d4553753 100644 --- a/src/components/SeekButton.ts +++ b/src/components/SeekButton.ts @@ -18,7 +18,7 @@ const DEFAULT_SEEK_OFFSET = 10; * @cssproperty `--theoplayer-seek-button-font-size` - The font size of the offset number rendered inside the seek icon. Defaults to `calc(0.3 * --theoplayer-button-icon-height)`. */ @customElement('theoplayer-seek-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class SeekButton extends Button { static styles = [...Button.styles, seekButtonCss]; @@ -30,6 +30,9 @@ export class SeekButton extends Button { @property({ reflect: true, type: Number, attribute: Attribute.SEEK_OFFSET }) accessor seekOffset: number = DEFAULT_SEEK_OFFSET; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } diff --git a/src/components/SettingsMenuButton.ts b/src/components/SettingsMenuButton.ts index a5161067..974d506b 100644 --- a/src/components/SettingsMenuButton.ts +++ b/src/components/SettingsMenuButton.ts @@ -1,9 +1,11 @@ import { html, type HTMLTemplateResult, type PropertyValues } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { MenuButton } from './MenuButton'; import settingsIcon from '../icons/settings.svg'; import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; /** * A menu button that opens a {@link SettingsMenu}. @@ -11,7 +13,11 @@ import { getLocale } from '../i18n'; * @attribute `menu` - The ID of the settings menu. */ @customElement('theoplayer-settings-menu-button') +@stateReceiver(['lang']) export class SettingsMenuButton extends MenuButton { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); this._updateAriaLabel(); diff --git a/src/components/TimeRange.ts b/src/components/TimeRange.ts index 040b8d33..629a9671 100644 --- a/src/components/TimeRange.ts +++ b/src/components/TimeRange.ts @@ -43,7 +43,7 @@ const AD_MARKER_WIDTH = 1; * Defaults to `0 0 4px rgba(0, 0, 0, 0.75)`. */ @customElement('theoplayer-time-range') -@stateReceiver(['player', 'streamType', 'deviceType']) +@stateReceiver(['player', 'streamType', 'deviceType', 'lang']) export class TimeRange extends Range { static override styles = [...Range.styles, timeRangeCss]; diff --git a/src/components/ads/AdClickThroughButton.ts b/src/components/ads/AdClickThroughButton.ts index 5cf8065b..c6ccd68c 100644 --- a/src/components/ads/AdClickThroughButton.ts +++ b/src/components/ads/AdClickThroughButton.ts @@ -14,7 +14,7 @@ const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as cons * A button to open the advertisement's click-through webpage. */ @customElement('theoplayer-ad-clickthrough-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class AdClickThroughButton extends LinkButton { private _player: ChromelessPlayer | undefined; private _ads: Ads | undefined; @@ -53,6 +53,9 @@ export class AdClickThroughButton extends LinkButton { this.style.display = clickThrough != null ? '' : 'none'; } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } diff --git a/src/components/ads/AdCountdown.ts b/src/components/ads/AdCountdown.ts index 28915867..da072fc4 100644 --- a/src/components/ads/AdCountdown.ts +++ b/src/components/ads/AdCountdown.ts @@ -6,6 +6,7 @@ import { stateReceiver } from '../StateReceiverMixin'; import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { getLocale } from '../../i18n'; import { toDuration } from '../../util/TimeUtils'; +import { Attribute } from '../../util/Attribute'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak'] as const; @@ -13,7 +14,7 @@ const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak * A control that displays the remaining time of the current ad break. */ @customElement('theoplayer-ad-countdown') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class AdCountdown extends LitElement { static override styles = [textDisplayCss, adCountdownCss]; @@ -23,6 +24,9 @@ export class AdCountdown extends LitElement { @state() private accessor _maxRemainingDuration: number = 0; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } diff --git a/src/components/ads/AdDisplay.ts b/src/components/ads/AdDisplay.ts index b741476b..5c288146 100644 --- a/src/components/ads/AdDisplay.ts +++ b/src/components/ads/AdDisplay.ts @@ -7,6 +7,7 @@ import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless'; import { arrayFind } from '../../util/CommonUtils'; import { isLinearAd } from '../../util/AdUtils'; import { getLocale } from '../../i18n'; +import { Attribute } from '../../util/Attribute'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak', 'adbegin', 'adend', 'adskip', 'addad', 'updatead'] as const; @@ -20,7 +21,7 @@ const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak * @cssproperty `--theoplayer-ad-display-text-color` - The text color of the ad display. Defaults to `#000`. */ @customElement('theoplayer-ad-display') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class AdDisplay extends LitElement { static styles = [textDisplayCss, adDisplayCss]; @@ -33,6 +34,9 @@ export class AdDisplay extends LitElement { @state() private accessor _totalAds: number = 0; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + connectedCallback(): void { super.connectedCallback(); this._updateFromPlayer(); diff --git a/src/components/ads/AdSkipButton.ts b/src/components/ads/AdSkipButton.ts index da98da37..a87649ed 100644 --- a/src/components/ads/AdSkipButton.ts +++ b/src/components/ads/AdSkipButton.ts @@ -34,7 +34,7 @@ const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as cons * @cssproperty `--theoplayer-ad-skip-icon-height` - The height of the skip button icon. Defaults to `--theoplayer-control-height`. */ @customElement('theoplayer-ad-skip-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class AdSkipButton extends Button { static styles = [...Button.styles, adSkipButtonCss]; @@ -47,6 +47,9 @@ export class AdSkipButton extends Button { @state() private accessor _timeToSkip: number = 0; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + override connectedCallback(): void { super.connectedCallback(); this._update(); diff --git a/src/components/theolive/quality/BadNetworkModeButton.ts b/src/components/theolive/quality/BadNetworkModeButton.ts index affe9bcd..38705b51 100644 --- a/src/components/theolive/quality/BadNetworkModeButton.ts +++ b/src/components/theolive/quality/BadNetworkModeButton.ts @@ -10,6 +10,7 @@ import { stateReceiver } from '../../StateReceiverMixin'; import { MenuButton } from '../../MenuButton'; import type { BadNetworkModeMenu } from './BadNetworkModeMenu'; import { getLocale } from '../../../i18n'; +import { Attribute } from '../../../util/Attribute'; /** * A menu button that opens a {@link BadNetworkModeMenu}. @@ -17,7 +18,7 @@ import { getLocale } from '../../../i18n'; * @attribute `menu` - The ID of the bad network menu. */ @customElement('theolive-bad-network-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class BadNetworkModeButton extends MenuButton { static styles = [...MenuButton.styles, badNetworkModeButtonCss]; @@ -35,6 +36,9 @@ export class BadNetworkModeButton extends MenuButton { this._inBadNetworkMode = false; }; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): TheoPlayer | undefined { return this._player; } From e35fc3e21ed834c7cf5bffc661c4871dd5158da7 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:10:57 +0200 Subject: [PATCH 25/54] Rework default duration formatters --- src/i18n/DurationFormatter.ts | 36 +++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/i18n/DurationFormatter.ts b/src/i18n/DurationFormatter.ts index aac69e09..45c279b6 100644 --- a/src/i18n/DurationFormatter.ts +++ b/src/i18n/DurationFormatter.ts @@ -16,17 +16,33 @@ export function durationFormatterForLocale(locale: string, style: 'long' | 'narr } export function defaultFormatDuration({ hours, minutes, seconds }: Duration): string { - return [ - hours === 0 ? '' : `${hours} hour${hours === 1 ? '' : 's'}`, - minutes === 0 ? '' : `${minutes} minute${minutes === 1 ? '' : 's'}`, - seconds === 0 && (hours > 0 || minutes > 0) ? '' : `${seconds} second${seconds === 1 ? '' : 's'}` - ] - .filter((part) => part !== '') - .join(', '); + let s = ''; + if (hours !== 0) { + s += `${hours} hour${hours === 1 ? '' : 's'}`; + } + if (minutes !== 0) { + if (s !== '') s += ', '; + s += `${minutes} minute${minutes === 1 ? '' : 's'}`; + } + if (seconds !== 0 || s === '') { + if (s !== '') s += ', '; + s += `${seconds} second${seconds === 1 ? '' : 's'}`; + } + return s; } export function defaultNarrowFormatDuration({ hours, minutes, seconds }: Duration): string { - return [hours === 0 ? '' : `${hours}h`, minutes === 0 ? '' : `${minutes}m`, seconds === 0 && (hours > 0 || minutes > 0) ? '' : `${seconds}s`] - .filter((part) => part !== '') - .join(' '); + let s = ''; + if (hours !== 0) { + s += `${hours}h`; + } + if (minutes !== 0) { + if (s !== '') s += ' '; + s += `${minutes}m`; + } + if (seconds !== 0 || s === '') { + if (s !== '') s += ' '; + s += `${seconds}s`; + } + return s; } From ad6dfb3fbcb8871e6185a83b650e3a6be4e7614d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:43:36 +0200 Subject: [PATCH 26/54] Internationalize VolumeRange --- src/components/VolumeRange.ts | 9 +++++++-- src/i18n/Locale.ts | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/VolumeRange.ts b/src/components/VolumeRange.ts index f6b030c0..6ed37ad2 100644 --- a/src/components/VolumeRange.ts +++ b/src/components/VolumeRange.ts @@ -2,6 +2,8 @@ import { Range } from './Range'; import { customElement, property } from 'lit/decorators.js'; import { stateReceiver } from './StateReceiverMixin'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; +import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; function formatAsPercentString(value: number, max: number) { return `${Math.round((value / max) * 100)}%`; @@ -12,7 +14,7 @@ function formatAsPercentString(value: number, max: number) { * and which changes the volume when clicked or dragged. */ @customElement('theoplayer-volume-range') -@stateReceiver(['player', 'deviceType']) +@stateReceiver(['player', 'deviceType', 'lang']) export class VolumeRange extends Range { private _player: ChromelessPlayer | undefined; @@ -31,6 +33,9 @@ export class VolumeRange extends Range { } } + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + get player(): ChromelessPlayer | undefined { return this._player; } @@ -58,7 +63,7 @@ export class VolumeRange extends Range { }; protected override getAriaLabel(): string { - return 'volume'; + return getLocale(this.lang).volumeAria; } protected override getAriaValueText(): string { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 8ed7c441..a73e87b8 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -16,7 +16,8 @@ import type { PlayButton, SeekButton, SettingsMenuButton, - TimeRange + TimeRange, + VolumeRange } from '../components'; export interface Locale { @@ -43,6 +44,10 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link MuteButton} when it is showing an "unmute" button. */ unmuteAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link VolumeRange}. + */ + volumeAria: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link TimeRange}. */ @@ -193,6 +198,7 @@ export const defaultLocale: Locale = { replayAria: 'replay', muteAria: 'mute', unmuteAria: 'unmute', + volumeAria: 'volume', seekAria: 'seek', seekForwardAria: (offset) => `seek forward by ${offset}`, seekBackwardAria: (offset) => `seek backward by ${offset}`, From 0704627c41f25cdeb34bdd6f4bf913f7c1e6d09b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:43:46 +0200 Subject: [PATCH 27/54] Add TODO --- src/i18n/LocaleRegistry.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/LocaleRegistry.ts b/src/i18n/LocaleRegistry.ts index 930b5f2e..27823963 100644 --- a/src/i18n/LocaleRegistry.ts +++ b/src/i18n/LocaleRegistry.ts @@ -18,6 +18,7 @@ export function getLocale(name: string): Locale { * @param locale The locale. */ export function addLocale(name: string, locale: Partial) { + // TODO Re-render all components that use this locale? localesByName[name] = { ...defaultLocale, ...locale, From 9d966b4c23d3df1613d203f57b2ba335938c0b5e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:55:54 +0200 Subject: [PATCH 28/54] Add `Locale.formatPercentage` --- src/components/VolumeRange.ts | 6 +----- src/i18n/Locale.ts | 17 ++++++++++++++++- src/i18n/LocaleRegistry.ts | 4 +++- src/i18n/PercentageFormatter.ts | 16 ++++++++++++++++ typedoc.config.mjs | 4 +++- 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 src/i18n/PercentageFormatter.ts diff --git a/src/components/VolumeRange.ts b/src/components/VolumeRange.ts index 6ed37ad2..f36c17b1 100644 --- a/src/components/VolumeRange.ts +++ b/src/components/VolumeRange.ts @@ -5,10 +5,6 @@ import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { Attribute } from '../util/Attribute'; import { getLocale } from '../i18n'; -function formatAsPercentString(value: number, max: number) { - return `${Math.round((value / max) * 100)}%`; -} - /** * A volume slider, showing the current audio volume of the player, * and which changes the volume when clicked or dragged. @@ -67,7 +63,7 @@ export class VolumeRange extends Range { } protected override getAriaValueText(): string { - return formatAsPercentString(this.value, this.max); + return getLocale(this.lang).formatPercentage(this.value / this.max); } protected override handleInput(): void { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index a73e87b8..a41db829 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -1,4 +1,5 @@ import { durationFormatterForLocale } from './DurationFormatter'; +import { percentageFormatterForLocale } from './PercentageFormatter'; import type { AdClickThroughButton, AdCountdown, @@ -181,9 +182,22 @@ export interface Locale { * @param duration A duration that was formatted with {@link formatDuration}. */ formatRemainingDuration(duration: string): string; + /** + * Formats the given percentage as a human-readable string. + * + * This is optional. If not provided, a default {@link Intl.NumberFormat} with the {@link Intl.NumberFormatOptions.style | `"percent"` style} is used. + * + * Examples: + * - `0.75` → "75%" + * - `1.0` → "100%" + * + * @param percentage A percentage, between `0.0` and `1.0`. + */ + formatPercentage(percentage: number): string; } export type DurationFormatter = (duration: Duration) => string; +export type PercentageFormatter = (percentage: number) => string; export interface Duration { hours: number; @@ -223,5 +237,6 @@ export const defaultLocale: Locale = { openBadNetworkModeMenuAria: 'open bad network mode menu', formatDuration: durationFormatterForLocale(defaultLocaleName, 'long'), formatNarrowDuration: durationFormatterForLocale(defaultLocaleName, 'narrow'), - formatRemainingDuration: (duration: string) => `${duration} remaining` + formatRemainingDuration: (duration: string) => `${duration} remaining`, + formatPercentage: percentageFormatterForLocale(defaultLocaleName) }; diff --git a/src/i18n/LocaleRegistry.ts b/src/i18n/LocaleRegistry.ts index 27823963..ac8baae6 100644 --- a/src/i18n/LocaleRegistry.ts +++ b/src/i18n/LocaleRegistry.ts @@ -1,5 +1,6 @@ import { defaultLocale, type Locale } from './Locale'; import { durationFormatterForLocale } from './DurationFormatter'; +import { percentageFormatterForLocale } from './PercentageFormatter'; const localesByName: Record> = {}; @@ -23,6 +24,7 @@ export function addLocale(name: string, locale: Partial) { ...defaultLocale, ...locale, formatDuration: locale.formatDuration ?? durationFormatterForLocale(name, 'long'), - formatNarrowDuration: locale.formatNarrowDuration ?? durationFormatterForLocale(name, 'narrow') + formatNarrowDuration: locale.formatNarrowDuration ?? durationFormatterForLocale(name, 'narrow'), + formatPercentage: locale.formatPercentage ?? percentageFormatterForLocale(name) }; } diff --git a/src/i18n/PercentageFormatter.ts b/src/i18n/PercentageFormatter.ts new file mode 100644 index 00000000..6a871597 --- /dev/null +++ b/src/i18n/PercentageFormatter.ts @@ -0,0 +1,16 @@ +import type { PercentageFormatter } from './Locale'; + +export function percentageFormatterForLocale(locale: string): PercentageFormatter { + try { + // Use Intl.NumberFormat (if supported). + const formatter = new Intl.NumberFormat(locale, { style: 'percent' }); + return (percentage) => formatter.format(percentage); + } catch { + // Fall back to English. + return defaultPercentageFormatter; + } +} + +export function defaultPercentageFormatter(percentage: number): string { + return `${Math.round(percentage * 100)}%`; +} diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 9cbe5c4a..43bbe234 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -31,7 +31,9 @@ export default { typescript: { 'ARIAMixin.ariaLabel': 'https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label', 'Intl.DurationFormatStyle': - 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style' + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style', + 'Intl.NumberFormatOptions.style': + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#style' }, 'lit-element': { LitElement: 'https://lit.dev/docs/api/LitElement/', From 761e413b974d6d531f68a4a2c513849bc5476843 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:58:05 +0200 Subject: [PATCH 29/54] Move stuff around --- src/i18n/DurationFormatter.ts | 4 +++- src/i18n/Locale.ts | 3 --- src/i18n/PercentageFormatter.ts | 2 +- src/i18n/index.ts | 4 +++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/i18n/DurationFormatter.ts b/src/i18n/DurationFormatter.ts index 45c279b6..07502989 100644 --- a/src/i18n/DurationFormatter.ts +++ b/src/i18n/DurationFormatter.ts @@ -1,4 +1,6 @@ -import type { Duration, DurationFormatter } from './Locale'; +import type { Duration } from './Locale'; + +export type DurationFormatter = (duration: Duration) => string; export function durationFormatterForLocale(locale: string, style: 'long' | 'narrow'): DurationFormatter { try { diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index a41db829..911e07d6 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -196,9 +196,6 @@ export interface Locale { formatPercentage(percentage: number): string; } -export type DurationFormatter = (duration: Duration) => string; -export type PercentageFormatter = (percentage: number) => string; - export interface Duration { hours: number; minutes: number; diff --git a/src/i18n/PercentageFormatter.ts b/src/i18n/PercentageFormatter.ts index 6a871597..1235f808 100644 --- a/src/i18n/PercentageFormatter.ts +++ b/src/i18n/PercentageFormatter.ts @@ -1,4 +1,4 @@ -import type { PercentageFormatter } from './Locale'; +export type PercentageFormatter = (percentage: number) => string; export function percentageFormatterForLocale(locale: string): PercentageFormatter { try { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index be163868..33932d31 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,2 +1,4 @@ -export { type Locale, type Duration, type DurationFormatter } from './Locale'; +export { type Locale, type Duration } from './Locale'; +export { type DurationFormatter } from './DurationFormatter'; +export { type PercentageFormatter } from './PercentageFormatter'; export { getLocale, addLocale } from './LocaleRegistry'; From c4b0899193b1f5846c4027fe70b22fbcaa010275 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 20:59:03 +0200 Subject: [PATCH 30/54] Don't export `DurationFormatter` --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 28fb414e..083e57bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,5 +8,5 @@ export { type DeviceType } from './util/DeviceType'; export { type StreamType } from './util/StreamType'; export { type Constructor } from './util/CommonUtils'; export { ColorStops } from './util/ColorStops'; -export { type Locale, type Duration, type DurationFormatter, addLocale } from './i18n/index'; +export { type Locale, type Duration, addLocale } from './i18n/index'; export * from './version'; From e3a5d6c17eabb9e9f5023124d50f9b5215547db6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 13 May 2026 21:02:33 +0200 Subject: [PATCH 31/54] Document `Duration` --- src/i18n/Locale.ts | 3 +++ tsconfig.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 911e07d6..b98bb69a 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -196,6 +196,9 @@ export interface Locale { formatPercentage(percentage: number): string; } +/** + * A duration, compatible with {@link Intl.DurationFormat.format} and {@link Temporal.Duration}. + */ export interface Duration { hours: number; minutes: number; diff --git a/tsconfig.json b/tsconfig.json index 29e870de..019fb34b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "useDefineForClassFields": true, "isolatedModules": true, "verbatimModuleSyntax": true, - "lib": ["dom", "es2015", "es2020.intl", "es2025.intl"], + "lib": ["dom", "es2015", "es2020.intl", "es2025.intl", "esnext.temporal"], "types": [] }, "include": ["src/**/*"] From fb23f27ec215bf6e5eb467042941d18865139cf7 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 21 May 2026 18:16:44 +0200 Subject: [PATCH 32/54] Internationalize PlaybackRateMenu and PlaybackRateDisplay --- src/DefaultUI.ts | 7 +------ src/components/PlaybackRateDisplay.ts | 12 +++++++++--- src/components/PlaybackRateMenu.ts | 17 ++++++++++++----- src/components/SettingsMenu.ts | 4 +--- src/i18n/Locale.ts | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index 5f3cc9c8..eaabcd77 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -3,12 +3,7 @@ import { customElement, property, queryAssignedNodes, state } from 'lit/decorato import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { ChromelessPlayer, SourceDescription, UIPlayerConfiguration } from 'theoplayer/chromeless'; -import { - DEFAULT_DVR_THRESHOLD, - DEFAULT_TV_USER_IDLE_TIMEOUT, - DEFAULT_USER_IDLE_TIMEOUT, - type UIContainer -} from './UIContainer'; +import { DEFAULT_DVR_THRESHOLD, DEFAULT_TV_USER_IDLE_TIMEOUT, DEFAULT_USER_IDLE_TIMEOUT, type UIContainer } from './UIContainer'; import defaultUiCss from './DefaultUI.css'; import { Attribute } from './util/Attribute'; import { applyExtensions } from './extensions/ExtensionRegistry'; diff --git a/src/components/PlaybackRateDisplay.ts b/src/components/PlaybackRateDisplay.ts index 619f9f7a..3f2a0e81 100644 --- a/src/components/PlaybackRateDisplay.ts +++ b/src/components/PlaybackRateDisplay.ts @@ -1,13 +1,18 @@ import { html, type HTMLTemplateResult, LitElement } from 'lit'; import { stateReceiver } from './StateReceiverMixin'; -import { customElement, state } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; /** * A control that displays the current playback rate of the player. */ @customElement('theoplayer-playback-rate-display') -@stateReceiver(['playbackRate']) +@stateReceiver(['playbackRate', 'lang']) export class PlaybackRateDisplay extends LitElement { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + /** * The current playback rate. */ @@ -15,7 +20,8 @@ export class PlaybackRateDisplay extends LitElement { accessor playbackRate: number = 1; protected override render(): HTMLTemplateResult { - return html`${this.playbackRate === 1 ? 'Normal' : `${this.playbackRate}x`}`; + const locale = getLocale(this.lang); + return html`${locale.formatPlaybackRate(this.playbackRate)}`; } } diff --git a/src/components/PlaybackRateMenu.ts b/src/components/PlaybackRateMenu.ts index 9e23a026..02edecb8 100644 --- a/src/components/PlaybackRateMenu.ts +++ b/src/components/PlaybackRateMenu.ts @@ -1,6 +1,9 @@ import { html, type HTMLTemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { Menu } from './Menu'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; // Load components used in template import './PlaybackRateRadioGroup'; @@ -13,16 +16,20 @@ const PLAYBACK_RATES = [0.25, 0.5, 1, 1.25, 1.5, 2]; * @slot `heading` - A slot for the menu's heading. */ @customElement('theoplayer-playback-rate-menu') +@stateReceiver(['lang']) export class PlaybackRateMenu extends Menu { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + protected override renderMenuHeading(): HTMLTemplateResult { - return html`Playback speed`; + const locale = getLocale(this.lang); + return html`${locale.playbackRateMenuHeading}`; } protected override renderMenuContent(): HTMLTemplateResult { + const locale = getLocale(this.lang); return html` - ${PLAYBACK_RATES.map( - (rate) => html`${rate === 1 ? 'Normal' : `${rate}x`}` - )} + ${PLAYBACK_RATES.map((rate) => html`${locale.formatPlaybackRate(rate)}`)} `; } } diff --git a/src/components/SettingsMenu.ts b/src/components/SettingsMenu.ts index 53a1e82c..56fa9b0b 100644 --- a/src/components/SettingsMenu.ts +++ b/src/components/SettingsMenu.ts @@ -45,9 +45,7 @@ export class SettingsMenu extends MenuGroup { Quality - + `; } } diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index b98bb69a..f26ca36c 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -13,6 +13,8 @@ import type { LanguageMenuButton, LiveButton, MuteButton, + PlaybackRateDisplay, + PlaybackRateMenu, PlaybackRateMenuButton, PlayButton, SeekButton, @@ -140,6 +142,20 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link PlaybackRateMenuButton}. */ openPlaybackRateMenuAria: string; + /** + * The heading for a {@link PlaybackRateMenu}. + */ + playbackRateMenuHeading: string; + /** + * Formats the given playback rate for a {@link PlaybackRateMenu} and {@link PlaybackRateDisplay}. + * + * Examples: + * - `1` → "Normal" + * - `1.5` → "1.5x" + * + * @param rate The playback rate. + */ + formatPlaybackRate(rate: number): string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link SettingsMenuButton}. */ @@ -233,6 +249,8 @@ export const defaultLocale: Locale = { closeMenuAria: 'close menu', openLanguageMenuAria: 'open language menu', openPlaybackRateMenuAria: 'open playback speed menu', + playbackRateMenuHeading: 'Playback speed', + formatPlaybackRate: (rate: number) => (rate === 1 ? 'Normal' : `${rate}x`), openSettingsMenuAria: 'open settings menu', openBadNetworkModeMenuAria: 'open bad network mode menu', formatDuration: durationFormatterForLocale(defaultLocaleName, 'long'), From a5197cbb17ea0e42cc1dd87cd15cfb11752ceb56 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 21 May 2026 18:21:07 +0200 Subject: [PATCH 33/54] Internationalize LanguageMenu --- src/components/LanguageMenu.ts | 13 +++++++++---- src/i18n/Locale.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/LanguageMenu.ts b/src/components/LanguageMenu.ts index 20615493..72c94b5e 100644 --- a/src/components/LanguageMenu.ts +++ b/src/components/LanguageMenu.ts @@ -6,6 +6,7 @@ import { stateReceiver } from './StateReceiverMixin'; import type { ChromelessPlayer, MediaTrack, MediaTrackList, TextTrack, TextTracksList } from 'theoplayer/chromeless'; import { isNonForcedSubtitleTrack } from '../util/TrackUtils'; import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; // Load components used in template import './TrackRadioGroup'; @@ -19,10 +20,13 @@ const TRACK_EVENTS = ['addtrack', 'removetrack'] as const; * @slot `heading` - A slot for the menu's heading. */ @customElement('theoplayer-language-menu') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class LanguageMenu extends MenuGroup { static styles = [...MenuGroup.styles, languageMenuCss]; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + private _player: ChromelessPlayer | undefined; private _audioTrackList: MediaTrackList | undefined; private _textTrackList: TextTracksList | undefined; @@ -66,9 +70,10 @@ export class LanguageMenu extends MenuGroup { }; protected override render(): TemplateResult { + const locale = getLocale(this.lang); return html` - Language + ${locale.languageMenuHeading}
-

Audio

+

${locale.audioMenuHeading}

-

Subtitles

+

${locale.subtitleMenuHeading}

diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index f26ca36c..7925684b 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -10,6 +10,7 @@ import type { ChromecastButton, CloseMenuButton, FullscreenButton, + LanguageMenu, LanguageMenuButton, LiveButton, MuteButton, @@ -138,6 +139,18 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link LanguageMenuButton}. */ openLanguageMenuAria: string; + /** + * The heading for a {@link LanguageMenu}. + */ + languageMenuHeading: string; + /** + * The section header for audio tracks in a {@link LanguageMenu}. + */ + audioMenuHeading: string; + /** + * The section header for subtitle tracks in a {@link LanguageMenu}. + */ + subtitleMenuHeading: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link PlaybackRateMenuButton}. */ @@ -248,6 +261,9 @@ export const defaultLocale: Locale = { adSkipCountdownText: (remainingDuration: string) => `Skip in ${remainingDuration}`, closeMenuAria: 'close menu', openLanguageMenuAria: 'open language menu', + languageMenuHeading: 'Language', + audioMenuHeading: 'Audio', + subtitleMenuHeading: 'Subtitles', openPlaybackRateMenuAria: 'open playback speed menu', playbackRateMenuHeading: 'Playback speed', formatPlaybackRate: (rate: number) => (rate === 1 ? 'Normal' : `${rate}x`), From 7804d177b3e666b8b5aedd5ed9d90e19ed39b7bd Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 22 May 2026 13:00:33 +0200 Subject: [PATCH 34/54] Internationalize SettingsMenu --- src/components/SettingsMenu.ts | 18 +++++++++++++----- src/i18n/Locale.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/SettingsMenu.ts b/src/components/SettingsMenu.ts index 56fa9b0b..4e0615c2 100644 --- a/src/components/SettingsMenu.ts +++ b/src/components/SettingsMenu.ts @@ -1,7 +1,10 @@ import { html, type TemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { MenuGroup } from './MenuGroup'; import menuTableCss from './MenuTable.css'; +import { stateReceiver } from './StateReceiverMixin'; +import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; // Load components used in template import './ActiveQualityDisplay'; @@ -15,16 +18,21 @@ import './PlaybackRateMenu'; * @slot `heading` - A slot for the menu's heading. */ @customElement('theoplayer-settings-menu') +@stateReceiver(['lang']) export class SettingsMenu extends MenuGroup { static styles = [...MenuGroup.styles, menuTableCss]; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + protected override render(): TemplateResult { + const locale = getLocale(this.lang); return html` - Settings + ${locale.settingsMenuHeading} - + - +
Quality${locale.qualityMenuHeading} @@ -32,7 +40,7 @@ export class SettingsMenu extends MenuGroup {
Playback speed${locale.playbackRateMenuHeading} @@ -42,7 +50,7 @@ export class SettingsMenu extends MenuGroup {
diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts index 7925684b..db1a121f 100644 --- a/src/i18n/Locale.ts +++ b/src/i18n/Locale.ts @@ -19,6 +19,7 @@ import type { PlaybackRateMenuButton, PlayButton, SeekButton, + SettingsMenu, SettingsMenuButton, TimeRange, VolumeRange @@ -173,6 +174,14 @@ export interface Locale { * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link SettingsMenuButton}. */ openSettingsMenuAria: string; + /** + * The heading for a {@link SettingsMenu}. + */ + settingsMenuHeading: string; + /** + * The text for the quality option in a {@link SettingsMenu}. + */ + qualityMenuHeading: string; /** * The {@link HTMLElement.ariaLabel | `aria-label`} for an {@link BadNetworkModeButton}. */ @@ -268,6 +277,8 @@ export const defaultLocale: Locale = { playbackRateMenuHeading: 'Playback speed', formatPlaybackRate: (rate: number) => (rate === 1 ? 'Normal' : `${rate}x`), openSettingsMenuAria: 'open settings menu', + settingsMenuHeading: 'Settings', + qualityMenuHeading: 'Quality', openBadNetworkModeMenuAria: 'open bad network mode menu', formatDuration: durationFormatterForLocale(defaultLocaleName, 'long'), formatNarrowDuration: durationFormatterForLocale(defaultLocaleName, 'narrow'), From 8a56b5dc318fbe51fbfecad92e80744afe057be4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 22 May 2026 13:15:50 +0200 Subject: [PATCH 35/54] Internationalize headings in TextTrackStyleMenu --- src/components/TextTrackStyleMenu.ts | 48 +++++++++++++++----------- src/i18n/Locale.ts | 51 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/components/TextTrackStyleMenu.ts b/src/components/TextTrackStyleMenu.ts index 91a65c47..d7b2b4ca 100644 --- a/src/components/TextTrackStyleMenu.ts +++ b/src/components/TextTrackStyleMenu.ts @@ -1,9 +1,12 @@ import { html, type TemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { MenuGroup } from './MenuGroup'; import textTrackStyleMenuCss from './TextTrackStyleMenu.css'; import menuTableCss from './MenuTable.css'; import type { EdgeStyle } from 'theoplayer/chromeless'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; // Load components used in template import './TextTrackStyleDisplay'; @@ -57,20 +60,25 @@ const edgeStyleOptions: ReadonlyArray<{ label: string; value: EdgeStyle | '' }> * @slot `heading` - A slot for the menu's heading. */ @customElement('theoplayer-text-track-style-menu') +@stateReceiver(['lang']) export class TextTrackStyleMenu extends MenuGroup { static styles = [...MenuGroup.styles, menuTableCss, textTrackStyleMenuCss]; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + protected override render(): TemplateResult { + const locale = getLocale(this.lang); return html` - Subtitle options + ${locale.textTrackStyleMenuHeading} - + - + - + - + - + - + - + - + - +
Font family${locale.textTrackStyleFontFamily} @@ -78,7 +86,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font color${locale.textTrackStyleFontColor} @@ -86,7 +94,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font opacity${locale.textTrackStyleFontOpacity} @@ -94,7 +102,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font size${locale.textTrackStyleFontSize} @@ -102,7 +110,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Background color${locale.textTrackStyleBackgroundColor} @@ -110,7 +118,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Background opacity${locale.textTrackStyleBackgroundOpacity} @@ -118,7 +126,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Window color${locale.textTrackStyleWindowColor} @@ -126,7 +134,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Window opacity${locale.textTrackStyleWindowOpacity} @@ -134,7 +142,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Character edge style${locale.textTrackStyleEdgeStyle} @@ -144,55 +152,55 @@ export class TextTrackStyleMenu extends MenuGroup {