Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0944d2e
Set up internationalization
MattiasBuelens May 6, 2025
c110d1f
Add lang to DefaultUI
MattiasBuelens May 6, 2025
5ee3c0c
Internationalize PlayButton
MattiasBuelens May 6, 2025
4ccf996
Internationalize TimeDisplay
MattiasBuelens Jan 21, 2026
daf0d66
Internationalize TimeRange
MattiasBuelens Jan 21, 2026
60cdde6
Use `Intl.DurationFormat` types from `es2025.intl`
MattiasBuelens May 12, 2026
c0db7d9
Add missing exports
MattiasBuelens May 12, 2026
1bea8fe
Document `Locale` interface
MattiasBuelens May 12, 2026
f12dbed
Restructure
MattiasBuelens May 12, 2026
d27ecc2
Use `willUpdate()` to update ARIA labels
MattiasBuelens May 12, 2026
4889bad
Add `Button.ariaLabel`
MattiasBuelens May 12, 2026
a973851
Internationalize AirPlayButton and ChromecastButton
MattiasBuelens May 12, 2026
758a71e
Fix BadNetworkModeButton
MattiasBuelens May 12, 2026
eefd89a
Export THEOlive components
MattiasBuelens May 12, 2026
13c6d1a
Internationalize menu buttons
MattiasBuelens May 12, 2026
167c606
Internationalize LiveButton
MattiasBuelens May 12, 2026
af09454
Internationalize FullscreenButton
MattiasBuelens May 12, 2026
361fe86
Internationalize MuteButton
MattiasBuelens May 12, 2026
57304e5
Internationalize SeekButton
MattiasBuelens May 12, 2026
bcd2737
Internationalize ad components
MattiasBuelens May 13, 2026
6c14536
Use narrow duration format for ad components
MattiasBuelens May 13, 2026
46e0c20
Extract `toDuration` helper
MattiasBuelens May 13, 2026
029174d
Rework `AdDisplay` state
MattiasBuelens May 13, 2026
82f7f1e
Push language down to all localized components
MattiasBuelens May 13, 2026
e35fc3e
Rework default duration formatters
MattiasBuelens May 13, 2026
ad6dfb3
Internationalize VolumeRange
MattiasBuelens May 13, 2026
0704627
Add TODO
MattiasBuelens May 13, 2026
9d966b4
Add `Locale.formatPercentage`
MattiasBuelens May 13, 2026
761e413
Move stuff around
MattiasBuelens May 13, 2026
c4b0899
Don't export `DurationFormatter`
MattiasBuelens May 13, 2026
e3a5d6c
Document `Duration`
MattiasBuelens May 13, 2026
fb23f27
Internationalize PlaybackRateMenu and PlaybackRateDisplay
MattiasBuelens May 21, 2026
a5197cb
Internationalize LanguageMenu
MattiasBuelens May 21, 2026
7804d17
Internationalize SettingsMenu
MattiasBuelens May 22, 2026
8a56b5d
Internationalize headings in TextTrackStyleMenu
MattiasBuelens May 22, 2026
2e264f7
Tweak Locale types
MattiasBuelens May 22, 2026
d5657ab
Internationalize style values in TextTrackStyleMenu and TextTrackStyl…
MattiasBuelens May 22, 2026
20009ef
Deduplicate style options
MattiasBuelens May 22, 2026
7abbf36
Export types
MattiasBuelens May 22, 2026
db2d226
Internationalize TextTrackOffRadioButton
MattiasBuelens May 22, 2026
1d6f198
Internationalize TextTrackStyleResetButton
MattiasBuelens May 22, 2026
b369dfc
Internationalize QualityRadioButton
MattiasBuelens May 22, 2026
5a6503c
Internationalize AutomaticQualitySelector and BadNetworkModeSelector
MattiasBuelens May 22, 2026
889ae01
Internationalize ActiveQualityDisplay
MattiasBuelens May 22, 2026
4b617a4
Use "Automatic" label only when QualityRadioButton has no associated …
MattiasBuelens May 22, 2026
2bb9b81
Simplify bandwidth formatter
MattiasBuelens May 22, 2026
6d6c2a3
Fix `lang` attribute not pushed through to shadow DOM children
MattiasBuelens May 22, 2026
868935f
Internationalize ErrorDisplay
MattiasBuelens May 22, 2026
524ba3f
Internationalize TimeDisplay and TimeRange
MattiasBuelens May 22, 2026
53860ec
Fix spelling
MattiasBuelens May 22, 2026
0037c54
Re-export internationalization APIs in React UI
MattiasBuelens May 22, 2026
e372dd3
Fix TypeDoc
MattiasBuelens May 22, 2026
bf19b69
Add `lang` to props in React UI
MattiasBuelens May 22, 2026
cbceeff
Update changelog
MattiasBuelens May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/gold-pants-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@theoplayer/react-ui': minor
'@theoplayer/web-ui': minor
---

Added localization support. Use `addLocale()` to register a locale, and set the `lang` attribute on the UI to apply it.
9 changes: 9 additions & 0 deletions react/src/DefaultUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createComponent, type WebComponentProps } from '@lit/react';
import { usePlayer } from './util';
import { PlayerContext } from './context';
import { type Menu, SlotContainer } from './components';
import { type addLocale, type Locale } from './i18n';

const RawDefaultUI = createComponent({
tagName: 'theoplayer-default-ui',
Expand Down Expand Up @@ -51,6 +52,14 @@ export interface DefaultUIProps extends PropsWithoutRef<Omit<WebComponentProps<D
* - {@link menu}
*/
children?: never;
/**
* The language of the UI.
*
* When set, this also updates the {@link Locale} of the UI if one is registered with {@link addLocale}.
*
* @see {@link HTMLElement.lang}
*/
lang?: string;
/**
* Called when the backing player is created.
*
Expand Down
9 changes: 9 additions & 0 deletions react/src/UIContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createComponent, type WebComponentProps } from '@lit/react';
import { usePlayer } from './util';
import { PlayerContext } from './context';
import { type ChromecastButton, type ErrorDisplay, type Menu, type PlayButton, SlotContainer, type TimeRange } from './components';
import { type addLocale, type Locale } from './i18n';

const RawUIContainer = createComponent({
tagName: 'theoplayer-ui',
Expand Down Expand Up @@ -51,6 +52,14 @@ export interface UIContainerProps extends PropsWithoutRef<WebComponentProps<UICo
* A slot for an error display, to show when the player encounters a fatal error (see {@link ErrorDisplay}).
*/
error?: ReactNode;
/**
* The language of the UI.
*
* When set, this also updates the {@link Locale} of the UI if one is registered with {@link addLocale}.
*
* @see {@link HTMLElement.lang}
*/
lang?: string;
/**
* Use a named slot instead, such as:
* - {@link topChrome}
Expand Down
1 change: 1 addition & 0 deletions react/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type Locale, type PartialLocale, type Duration, type KnownColor, type KnownFontFamily, addLocale } from '@theoplayer/web-ui';
1 change: 1 addition & 0 deletions react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export * from './DefaultUI';
export * from './THEOliveDefaultUI';
export * from './components/index';
export * from './hooks/index';
export * from './i18n';
export * from './version';
3 changes: 3 additions & 0 deletions react/typedoc.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import webConfig from '../typedoc.config.mjs';

/** @type {import('typedoc').TypeDocOptions} */
export default {
extends: ['../typedoc.config.mjs'],
Expand All @@ -21,6 +23,7 @@ export default {
}
},
externalSymbolLinkMappings: {
...webConfig.externalSymbolLinkMappings,
react: {
'*': 'https://react.dev/reference/react'
}
Expand Down
25 changes: 25 additions & 0 deletions src/DefaultUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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';

/**
Expand Down Expand Up @@ -94,6 +95,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;
Expand Down Expand Up @@ -183,6 +185,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 {@link 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.
*
Expand Down Expand Up @@ -281,6 +302,9 @@ export class DefaultUI extends LitElement {
connectedCallback(): void {
super.connectedCallback();

if (!this.hasAttribute(Attribute.LANG)) {
this.lang = closestRecursive<HTMLElement>(this, '[lang]')?.lang ?? '';
}
if (!this.hasAttribute(Attribute.DEVICE_TYPE)) {
this.deviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop';
}
Expand Down Expand Up @@ -337,6 +361,7 @@ export class DefaultUI extends LitElement {
return html`<theoplayer-ui
${ref(this._uiRef)}
.configuration=${this.configuration}
.lang=${this.lang}
.fluid=${this.fluid}
.muted=${this.muted}
.autoplay=${this.autoplay}
Expand Down
30 changes: 30 additions & 0 deletions src/UIContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import elementCss from './UIContainer.css';
import {
arrayFind,
arrayRemove,
closestRecursive,
containsComposedNode,
getFocusableChildren,
getSlottedElements,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {@link 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.
*/
Expand Down Expand Up @@ -485,6 +509,9 @@ export class UIContainer extends LitElement {
super.connectedCallback();
addGlobalStyles();

if (!this.hasAttribute(Attribute.LANG)) {
this.lang = closestRecursive<HTMLElement>(this, '[lang]')?.lang ?? '';
}
if (!this.hasAttribute(Attribute.DEVICE_TYPE)) {
this.deviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop';
}
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 9 additions & 3 deletions src/components/ActiveQualityDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { customElement, property } from 'lit/decorators.js';
import { stateReceiver } from './StateReceiverMixin';
import type { VideoQuality } from 'theoplayer/chromeless';
import { formatQualityLabel } from '../util/TrackUtils';
import { Attribute } from '../util/Attribute';
import { getLocale } from '../i18n';

/**
* A control that displays the name of the active video quality.
Expand All @@ -22,21 +24,25 @@ export class ActiveQualityDisplay extends LitElement {
@property({ reflect: false, attribute: false })
accessor targetVideoQualities: VideoQuality[] | undefined = undefined;

@property({ reflect: true, type: String, attribute: Attribute.LANG })
accessor lang: string = '';

protected override render(): HTMLTemplateResult {
const locale = getLocale(this.lang);
// If no target quality is selected, or more than one target quality is selected,
// treat as "automatic" quality selection.
const hasSingleTargetQuality = this.targetVideoQualities !== undefined && this.targetVideoQualities.length === 1;
const targetQuality = hasSingleTargetQuality ? this.targetVideoQualities![0] : undefined;
// Always show the target quality immediately, even if it's not the active quality yet.
const selectedQuality = targetQuality ?? this.activeVideoQuality;
const qualityLabel = formatQualityLabel(selectedQuality);
const qualityLabel = formatQualityLabel(locale, selectedQuality);
let label: string;
if (hasSingleTargetQuality) {
// Manual quality selection: "720p" or "Unknown"
label = qualityLabel ?? `Unknown`;
label = qualityLabel ?? locale.unknownQualityLabel;
} else {
// Automatic quality selection: "Automatic" or "Automatic (720p)"
label = `Automatic${qualityLabel ? ` (${qualityLabel})` : ''}`;
label = `${locale.automaticQualityLabel}${qualityLabel ? ` (${qualityLabel})` : ''}`;
}
return html`<span>${label}</span>`;
}
Expand Down
21 changes: 12 additions & 9 deletions src/components/AirPlayButton.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { customElement } from 'lit/decorators.js';
import { type PropertyValues } from 'lit';
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];

Expand All @@ -30,16 +32,17 @@ 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();
}
@property({ reflect: true, type: String, attribute: Attribute.LANG })
accessor lang: string = '';

override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
this._updateAriaLabel();
}

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);
const locale = getLocale(this.lang);
this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? locale.airplayConnectedAria : locale.airplayAria;
}

protected override render() {
Expand Down
3 changes: 3 additions & 0 deletions src/components/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 12 additions & 10 deletions src/components/ChromecastButton.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { type PropertyValues } from 'lit';
import type { ChromelessPlayer } from 'theoplayer/chromeless';
import { stateReceiver } from './StateReceiverMixin';
import { CastButton } from './CastButton';
import chromecastButtonHtml from './ChromecastButton.html';
import chromecastButtonCss from './ChromecastButton.css';
import { customElement, property } from 'lit/decorators.js';
import { getLocale } from '../i18n';
import { Attribute } from '../util/Attribute';
import { customElement } from 'lit/decorators.js';

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];

Expand All @@ -37,17 +39,17 @@ 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();
}
@property({ reflect: true, type: String, attribute: Attribute.LANG })
accessor lang: string = '';

override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
this._updateAriaLabel();
}

private _updateAriaLabel(): void {
const label =
this.castState === 'connecting' || this.castState === 'connected' ? 'stop casting to Chromecast' : 'start casting to Chromecast';
this.setAttribute(Attribute.ARIA_LABEL, label);
const locale = getLocale(this.lang);
this.ariaLabel = this.castState === 'connecting' || this.castState === 'connected' ? locale.chromecastConnectedAria : locale.chromecastAria;
}

protected override render() {
Expand Down
Loading