diff --git a/.changeset/gold-pants-sleep.md b/.changeset/gold-pants-sleep.md new file mode 100644 index 00000000..9548daf3 --- /dev/null +++ b/.changeset/gold-pants-sleep.md @@ -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. diff --git a/react/src/DefaultUI.tsx b/react/src/DefaultUI.tsx index 4129c13a..713f86c6 100644 --- a/react/src/DefaultUI.tsx +++ b/react/src/DefaultUI.tsx @@ -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', @@ -51,6 +52,14 @@ export interface DefaultUIProps extends PropsWithoutRef(this, '[lang]')?.lang ?? ''; + } if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { this.deviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; } @@ -337,6 +361,7 @@ export class DefaultUI extends LitElement { return html`= 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/ActiveQualityDisplay.ts b/src/components/ActiveQualityDisplay.ts index e1cc5d45..b9cab3f7 100644 --- a/src/components/ActiveQualityDisplay.ts +++ b/src/components/ActiveQualityDisplay.ts @@ -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. @@ -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`${label}`; } diff --git a/src/components/AirPlayButton.ts b/src/components/AirPlayButton.ts index 67af8ae1..cfbd7112 100644 --- a/src/components/AirPlayButton.ts +++ b/src/components/AirPlayButton.ts @@ -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]; @@ -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() { 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 f27026e4..96bd0977 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -1,10 +1,12 @@ +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; @@ -12,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]; @@ -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() { diff --git a/src/components/CloseMenuButton.ts b/src/components/CloseMenuButton.ts index c935af97..2d97efe5 100644 --- a/src/components/CloseMenuButton.ts +++ b/src/components/CloseMenuButton.ts @@ -1,11 +1,13 @@ -import { html, type HTMLTemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +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'; 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,15 +15,8 @@ import { Attribute } from '../util/Attribute'; * This button must be placed inside a {@link Menu | ``}. */ @customElement('theoplayer-menu-close-button') +@stateReceiver(['lang']) export class CloseMenuButton extends Button { - override connectedCallback() { - super.connectedCallback(); - - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'close menu'); - } - } - protected override handleClick() { const event: CloseMenuEvent = createCustomEvent(CLOSE_MENU_EVENT, { bubbles: true, @@ -30,6 +25,19 @@ 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(); + } + + 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/ErrorDisplay.ts b/src/components/ErrorDisplay.ts index 7e34ec71..c22b1f19 100644 --- a/src/components/ErrorDisplay.ts +++ b/src/components/ErrorDisplay.ts @@ -6,6 +6,7 @@ import errorIcon from '../icons/error.svg'; import { stateReceiver } from './StateReceiverMixin'; import type { THEOplayerError } from 'theoplayer/chromeless'; import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; /** * A screen that shows the details of a fatal player error. @@ -22,7 +23,7 @@ import { Attribute } from '../util/Attribute'; * @cssproperty `--theoplayer-error-max-width` - The maximum width of the error display. Defaults to `80%`. */ @customElement('theoplayer-error-display') -@stateReceiver(['error', 'fullscreen']) +@stateReceiver(['error', 'fullscreen', 'lang']) export class ErrorDisplay extends LitElement { static override styles = [errorDisplayCss]; static override shadowRootOptions: ShadowRootInit = { @@ -42,10 +43,14 @@ export class ErrorDisplay extends LitElement { @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 render() { + const locale = getLocale(this.lang); return html`
${unsafeSVG(errorIcon)}
-

An error occurred

+

${locale.errorHeading}

${this.error?.message ?? ''}

diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index abd3737d..d5d6fdba 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'; @@ -10,12 +10,13 @@ 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. */ @customElement('theoplayer-fullscreen-button') -@stateReceiver(['fullscreen']) +@stateReceiver(['fullscreen', 'lang']) export class FullscreenButton extends Button { static styles = [...Button.styles, fullscreenButtonCss]; @@ -30,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, { @@ -46,16 +50,14 @@ 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 { - const label = this.fullscreen ? 'exit fullscreen' : 'enter fullscreen'; - this.setAttribute(Attribute.ARIA_LABEL, label); + const locale = getLocale(this.lang); + this.ariaLabel = this.fullscreen ? locale.fullscreenExitAria : locale.fullscreenAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/LanguageMenu.ts b/src/components/LanguageMenu.ts index 20615493..752627bb 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,26 +70,29 @@ export class LanguageMenu extends MenuGroup { }; protected override render(): TemplateResult { + const locale = getLocale(this.lang); + // FIXME: UIContainer doesn't push `lang` through shadow DOM children? return html` - Language + ${locale.languageMenuHeading}
-

Audio

+

${locale.audioMenuHeading}

-

Subtitles

+

${locale.subtitleMenuHeading}

- + `; } } diff --git a/src/components/LanguageMenuButton.ts b/src/components/LanguageMenuButton.ts index db643470..310ffd60 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; @@ -17,20 +18,12 @@ 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; private _textTrackList: TextTracksList | undefined; - override connectedCallback() { - super.connectedCallback(); - - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open language menu'); - } - } - get player(): ChromelessPlayer | undefined { return this._player; } @@ -50,12 +43,25 @@ 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)); 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/LiveButton.ts b/src/components/LiveButton.ts index 6a2b3e87..64a28461 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; @@ -22,21 +23,13 @@ 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]; private _player: ChromelessPlayer | undefined; private _liveThreshold: number = DEFAULT_LIVE_THRESHOLD; - connectedCallback() { - super.connectedCallback(); - - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'seek to live'); - } - } - /** * Whether the player is paused. */ @@ -71,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; } @@ -113,9 +109,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/components/MuteButton.ts b/src/components/MuteButton.ts index d53fbfa5..de95aad4 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'; @@ -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'; @@ -18,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]; @@ -38,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; } @@ -80,16 +84,14 @@ 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 { - const label = this.volumeLevel === 'off' ? 'unmute' : 'mute'; - this.setAttribute(Attribute.ARIA_LABEL, label); + const locale = getLocale(this.lang); + this.ariaLabel = this.volumeLevel === 'off' ? locale.unmuteAria : locale.muteAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/PlayButton.ts b/src/components/PlayButton.ts index bdde4575..40dd466d 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'; @@ -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; } @@ -107,16 +111,14 @@ 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 { - const label = this.ended ? 'replay' : this.paused ? 'play' : 'pause'; - this.setAttribute(Attribute.ARIA_LABEL, label); + const locale = getLocale(this.lang); + this.ariaLabel = this.ended ? locale.replayAria : this.paused ? locale.playAria : locale.pauseAria; } protected override render(): HTMLTemplateResult { 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/PlaybackRateMenuButton.ts b/src/components/PlaybackRateMenuButton.ts index 4aef34f5..89ba58ac 100644 --- a/src/components/PlaybackRateMenuButton.ts +++ b/src/components/PlaybackRateMenuButton.ts @@ -1,8 +1,10 @@ -import { html, type HTMLTemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +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'; import speedIcon from '../icons/speed.svg'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; /** @@ -11,13 +13,19 @@ import { Attribute } from '../util/Attribute'; * @attribute menu - The ID of the playback rate menu. */ @customElement('theoplayer-playback-rate-menu-button') +@stateReceiver(['lang']) export class PlaybackRateMenuButton extends MenuButton { - override connectedCallback() { - super.connectedCallback(); + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open playback speed menu'); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openPlaybackRateMenuAria; } protected override render(): HTMLTemplateResult { diff --git a/src/components/QualityRadioButton.ts b/src/components/QualityRadioButton.ts index e9141e89..f65852b5 100644 --- a/src/components/QualityRadioButton.ts +++ b/src/components/QualityRadioButton.ts @@ -1,8 +1,11 @@ import { html, type HTMLTemplateResult } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { RadioButton } from './RadioButton'; import type { MediaTrack, VideoQuality } from 'theoplayer/chromeless'; import { formatQualityLabel } from '../util/TrackUtils'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; const TRACK_EVENTS = ['activequalitychanged', 'targetqualitychanged'] as const; const QUALITY_EVENTS = ['update'] as const; @@ -12,12 +15,13 @@ const QUALITY_EVENTS = ['update'] as const; * and switches the video track's {@link theoplayer!MediaTrack.targetQuality | target quality} to that quality when clicked. */ @customElement('theoplayer-quality-radio-button') +@stateReceiver(['lang']) export class QualityRadioButton extends RadioButton { private _track: MediaTrack | undefined = undefined; private _quality: VideoQuality | undefined = undefined; - @state() - private accessor _qualityLabel = ''; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; /** * The video track containing the quality being controlled. @@ -83,7 +87,7 @@ export class QualityRadioButton extends RadioButton { }; private readonly _updateFromQuality = () => { - this._qualityLabel = formatQualityLabel(this._quality) ?? 'Automatic'; + this.requestUpdate(); }; protected override handleChange(): void { @@ -97,7 +101,10 @@ export class QualityRadioButton extends RadioButton { } protected override render(): HTMLTemplateResult { - return html`${this._qualityLabel}`; + const locale = getLocale(this.lang); + const label = + this._quality === undefined ? locale.automaticQualityLabel : (formatQualityLabel(locale, this._quality) ?? locale.unknownQualityLabel); + return html`${label}`; } } diff --git a/src/components/QualityRadioGroup.ts b/src/components/QualityRadioGroup.ts index 62e77ffa..545dc43e 100644 --- a/src/components/QualityRadioGroup.ts +++ b/src/components/QualityRadioGroup.ts @@ -1,11 +1,12 @@ import { html, type HTMLTemplateResult, LitElement } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import verticalRadioGroupCss from './VerticalRadioGroup.css'; import { stateReceiver } from './StateReceiverMixin'; import type { ChromelessPlayer, MediaTrack, MediaTrackList, Quality, VideoQuality } from 'theoplayer/chromeless'; import { arrayFind } from '../util/CommonUtils'; import { createEvent } from '../util/EventUtils'; import { repeat } from 'lit/directives/repeat.js'; +import { Attribute } from '../util/Attribute'; const TRACK_EVENTS = ['addtrack', 'removetrack', 'change'] as const; @@ -14,7 +15,7 @@ const TRACK_EVENTS = ['addtrack', 'removetrack', 'change'] as const; * from which the user can choose a desired target quality. */ @customElement('theoplayer-quality-radio-group') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class QualityRadioGroup extends LitElement { static override styles = [verticalRadioGroupCss]; @@ -24,6 +25,9 @@ export class QualityRadioGroup extends LitElement { @state() private accessor _track: MediaTrack | undefined; + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + protected override firstUpdated(): void { this._updateTrack(); } @@ -63,9 +67,10 @@ export class QualityRadioGroup extends LitElement { protected override render(): HTMLTemplateResult { const qualities: VideoQuality[] = this._track ? (this._track.qualities as Quality[] as VideoQuality[]) : []; + // FIXME: UIContainer doesn't push `lang` through shadow DOM children? return html` - + ${ /* If there is only one available quality, *only* show the "Automatic" option (without the single quality). */ qualities.length !== 1 && @@ -73,7 +78,11 @@ export class QualityRadioGroup extends LitElement { qualities, (quality) => quality.uid, (quality) => - html`` + html`` ) } diff --git a/src/components/SeekButton.ts b/src/components/SeekButton.ts index 2d0811cc..d4553753 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'; @@ -7,6 +7,8 @@ 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'; +import { toDuration } from '../util/TimeUtils'; const DEFAULT_SEEK_OFFSET = 10; @@ -16,23 +18,21 @@ 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]; 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). */ @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; } @@ -53,17 +53,16 @@ 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 { + const locale = getLocale(this.lang); const seekOffset = this.seekOffset; - const label = seekOffset >= 0 ? `seek forward by ${seekOffset} seconds` : `seek backward by ${-seekOffset} seconds`; - this.setAttribute(Attribute.ARIA_LABEL, label); + const duration = locale.formatDuration(toDuration(Math.abs(seekOffset))); + this.ariaLabel = seekOffset >= 0 ? locale.seekForwardAria(duration) : locale.seekBackwardAria(duration); } protected override render() { diff --git a/src/components/SettingsMenu.ts b/src/components/SettingsMenu.ts index 53a1e82c..666c098d 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,39 +18,43 @@ 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); + // FIXME: UIContainer doesn't push `lang` through shadow DOM children? return html` - Settings + ${locale.settingsMenuHeading} - + - +
Quality${locale.qualityMenuHeading} - +
Playback speed${locale.playbackRateMenuHeading} - +
- + `; } } diff --git a/src/components/SettingsMenuButton.ts b/src/components/SettingsMenuButton.ts index 08bcc82a..974d506b 100644 --- a/src/components/SettingsMenuButton.ts +++ b/src/components/SettingsMenuButton.ts @@ -1,8 +1,10 @@ -import { html, type HTMLTemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +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'; import settingsIcon from '../icons/settings.svg'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; /** @@ -11,13 +13,19 @@ import { Attribute } from '../util/Attribute'; * @attribute `menu` - The ID of the settings menu. */ @customElement('theoplayer-settings-menu-button') +@stateReceiver(['lang']) export class SettingsMenuButton extends MenuButton { - override connectedCallback() { - super.connectedCallback(); + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open settings menu'); - } + override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + this._updateAriaLabel(); + } + + private _updateAriaLabel(): void { + const locale = getLocale(this.lang); + this.ariaLabel = locale.openSettingsMenuAria; } protected override render(): HTMLTemplateResult { 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/components/TextTrackOffRadioButton.ts b/src/components/TextTrackOffRadioButton.ts index 934455b0..46a7508b 100644 --- a/src/components/TextTrackOffRadioButton.ts +++ b/src/components/TextTrackOffRadioButton.ts @@ -3,6 +3,9 @@ import { customElement, property } from 'lit/decorators.js'; import { RadioButton } from './RadioButton'; import type { TextTracksList } from 'theoplayer/chromeless'; import { isNonForcedSubtitleTrack, isSubtitleTrack } from '../util/TrackUtils'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; const TRACK_EVENTS = ['change'] as const; @@ -10,7 +13,11 @@ const TRACK_EVENTS = ['change'] as const; * A radio button that disables the active subtitle track when clicked. */ @customElement('theoplayer-text-track-off-radio-button') +@stateReceiver(['lang']) export class TextTrackOffRadioButton extends RadioButton { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + private _trackList: TextTracksList | undefined = undefined; get trackList(): TextTracksList | undefined { @@ -48,7 +55,8 @@ export class TextTrackOffRadioButton extends RadioButton { } protected override render(): HTMLTemplateResult { - return html`Off`; + const locale = getLocale(this.lang); + return html`${locale.subtitleOff}`; } } diff --git a/src/components/TextTrackStyleDisplay.ts b/src/components/TextTrackStyleDisplay.ts index 74a6859c..4c46ba22 100644 --- a/src/components/TextTrackStyleDisplay.ts +++ b/src/components/TextTrackStyleDisplay.ts @@ -6,15 +6,19 @@ import { Attribute } from '../util/Attribute'; import { parseColor, toRgb } from '../util/ColorUtils'; import type { TextTrackStyleOption } from './TextTrackStyleRadioGroup'; import { arrayFind } from '../util/CommonUtils'; -import { knownColors, knownEdgeStyles, knownFontFamilies } from '../util/TextTrackStylePresets'; +import { colorOptions, fontFamilyOptions } from '../util/TextTrackStylePresets'; +import { getLocale, type Locale } from '../i18n'; /** * A control that displays the value of a single text track style option * in a human-readable format. */ @customElement('theoplayer-text-track-style-display') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class TextTrackStyleDisplay extends LitElement { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + private _player: ChromelessPlayer | undefined; private _property: TextTrackStyleOption = 'fontColor'; private _textTrackStyle: TextTrackStyle | undefined; @@ -53,38 +57,39 @@ export class TextTrackStyleDisplay extends LitElement { private readonly _updateFromPlayer = () => this.requestUpdate(); protected override render(): HTMLTemplateResult { - return html`${this.renderValue()}`; + const locale = getLocale(this.lang); + return html`${this.renderValue(locale)}`; } - private renderValue(): string { + private renderValue(locale: Locale): string { if (this._player === undefined) { return ''; } const property = this.property; switch (property) { case 'fontFamily': { - return getFontFamilyLabel(this._player.textTrackStyle.fontFamily); + return getFontFamilyLabel(this._player.textTrackStyle.fontFamily, locale); } case 'fontColor': case 'backgroundColor': case 'windowColor': { - return getColorLabel(this._player.textTrackStyle[property]); + return getColorLabel(this._player.textTrackStyle[property], locale); } case 'fontOpacity': { - return getOpacityLabel(this._player.textTrackStyle.fontColor); + return getOpacityLabel(this._player.textTrackStyle.fontColor, locale); } case 'backgroundOpacity': { - return getOpacityLabel(this._player.textTrackStyle.backgroundColor); + return getOpacityLabel(this._player.textTrackStyle.backgroundColor, locale); } case 'windowOpacity': { - return getOpacityLabel(this._player.textTrackStyle.windowColor); + return getOpacityLabel(this._player.textTrackStyle.windowColor, locale); } case 'edgeStyle': { - return getEdgeStyleLabel(this._player.textTrackStyle.edgeStyle); + return getEdgeStyleLabel(this._player.textTrackStyle.edgeStyle, locale); } default: { const value = this._player.textTrackStyle[property]; - return value ? value : 'Default'; + return value ? value : locale.textTrackStyleDefaultLabel; } } } @@ -96,50 +101,46 @@ declare global { } } -function getFontFamilyLabel(fontFamily: string | null | undefined): string { +function getFontFamilyLabel(fontFamily: string | null | undefined, locale: Locale): string { if (!fontFamily) { - return 'Default'; + return locale.textTrackStyleDefaultLabel; } - const knownFontFamily = arrayFind(knownFontFamilies, ([_, value]) => value === fontFamily); + const knownFontFamily = arrayFind(fontFamilyOptions, ({ value }) => value === fontFamily); if (knownFontFamily) { - return knownFontFamily[0]; + return locale.fontFamilyLabels[knownFontFamily.label] ?? knownFontFamily.label; } - return 'Custom'; + return locale.textTrackStyleCustomLabel; } -function getColorLabel(color: string | null | undefined): string { +function getColorLabel(color: string | null | undefined, locale: Locale): string { if (!color) { - return 'Default'; + return locale.textTrackStyleDefaultLabel; } const parsedColor = parseColor(color); if (parsedColor) { const colorRgb = toRgb(parsedColor); - const knownColor = arrayFind(knownColors, ([_, value]) => value === colorRgb); + const knownColor = arrayFind(colorOptions, ({ value }) => value === colorRgb); if (knownColor) { - return knownColor[0]; + return locale.colorLabels[knownColor.label] ?? knownColor.label; } } - return 'Custom'; + return locale.textTrackStyleCustomLabel; } -function getOpacityLabel(color: string | null | undefined): string { +function getOpacityLabel(color: string | null | undefined, locale: Locale): string { if (!color) { - return 'Default'; + return locale.textTrackStyleDefaultLabel; } const parsedColor = parseColor(color); if (parsedColor) { - return `${parsedColor.a_ * 100}%`; + return locale.formatPercentage(parsedColor.a_); } - return 'Custom'; + return locale.textTrackStyleCustomLabel; } -function getEdgeStyleLabel(edgeStyle: EdgeStyle | null | undefined): string { +function getEdgeStyleLabel(edgeStyle: EdgeStyle | null | undefined, locale: Locale): string { if (!edgeStyle) { - return 'Default'; - } - const knownEdgeStyle = arrayFind(knownEdgeStyles, ([_, value]) => value === edgeStyle); - if (knownEdgeStyle) { - return knownEdgeStyle[0]; + return locale.textTrackStyleDefaultLabel; } - return 'Custom'; + return locale.edgeStyleLabels[edgeStyle] ?? locale.textTrackStyleCustomLabel; } diff --git a/src/components/TextTrackStyleMenu.ts b/src/components/TextTrackStyleMenu.ts index 91a65c47..53cc1a5d 100644 --- a/src/components/TextTrackStyleMenu.ts +++ b/src/components/TextTrackStyleMenu.ts @@ -1,76 +1,42 @@ 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 { colorOptions, edgeStyleOptions, fontFamilyOptions, opacityOptions, sizeOptions } from '../util/TextTrackStylePresets'; +import { getLocale } from '../i18n'; +import { stateReceiver } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; // Load components used in template import './TextTrackStyleDisplay'; import './TextTrackStyleRadioGroup'; -const colorOptions: ReadonlyArray<{ label: string; value: `rgb(${number},${number},${number})` | '' }> = [ - { label: 'Default', value: '' }, - { label: 'White', value: 'rgb(255,255,255)' }, - { label: 'Yellow', value: 'rgb(255,255,0)' }, - { label: 'Green', value: 'rgb(0,255,0)' }, - { label: 'Cyan', value: 'rgb(0,255,255)' }, - { label: 'Blue', value: 'rgb(0,0,255)' }, - { label: 'Magenta', value: 'rgb(255,0,255)' }, - { label: 'Red', value: 'rgb(255,0,0)' }, - { label: 'Black', value: 'rgb(0,0,0)' } -]; -const fontFamilyOptions: ReadonlyArray<{ label: string; value: string }> = [ - { label: 'Default', value: '' }, - { label: 'Monospace Serif', value: '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace' }, - { label: 'Proportional Serif', value: '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif' }, - { label: 'Monospace Sans', value: '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace' }, - { label: 'Proportional Sans', value: 'Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif' } -]; -const sizeOptions: ReadonlyArray<{ label: string; value: `${number}%` | '' }> = [ - { label: 'Default', value: '' }, - { label: '50%', value: '50%' }, - { label: '75%', value: '75%' }, - { label: '100%', value: '100%' }, - { label: '150%', value: '150%' }, - { label: '200%', value: '200%' } -]; -const opacityOptions: ReadonlyArray<{ label: string; value: `${number}` | '' }> = [ - { label: 'Default', value: '' }, - { label: '25%', value: '25' }, - { label: '50%', value: '50' }, - { label: '75%', value: '75' }, - { label: '100%', value: '100' } -]; -const edgeStyleOptions: ReadonlyArray<{ label: string; value: EdgeStyle | '' }> = [ - { label: 'Default', value: '' }, - { label: 'None', value: 'none' }, - { label: 'Drop shadow', value: 'dropshadow' }, - { label: 'Raised', value: 'raised' }, - { label: 'Depressed', value: 'depressed' }, - { label: 'Uniform', value: 'uniform' } -]; - /** * A menu to change the {@link theoplayer!TextTrackStyle | text track style} of the player. * * @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 +44,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font color${locale.textTrackStyleFontColor} @@ -86,7 +52,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font opacity${locale.textTrackStyleFontOpacity} @@ -94,7 +60,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Font size${locale.textTrackStyleFontSize} @@ -102,7 +68,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Background color${locale.textTrackStyleBackgroundColor} @@ -110,7 +76,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Background opacity${locale.textTrackStyleBackgroundOpacity} @@ -118,7 +84,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Window color${locale.textTrackStyleWindowColor} @@ -126,7 +92,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Window opacity${locale.textTrackStyleWindowOpacity} @@ -134,7 +100,7 @@ export class TextTrackStyleMenu extends MenuGroup {
Character edge style${locale.textTrackStyleEdgeStyle} @@ -144,57 +110,88 @@ export class TextTrackStyleMenu extends MenuGroup {
`; diff --git a/src/components/TextTrackStyleResetButton.ts b/src/components/TextTrackStyleResetButton.ts index 9b41e303..a4350fbe 100644 --- a/src/components/TextTrackStyleResetButton.ts +++ b/src/components/TextTrackStyleResetButton.ts @@ -1,15 +1,20 @@ import { html, type HTMLTemplateResult } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { Button } from './Button'; import { stateReceiver } from './StateReceiverMixin'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; +import { getLocale } from '../i18n'; +import { Attribute } from '../util/Attribute'; /** * A button that resets the text track style. */ @customElement('theoplayer-text-track-style-reset-button') -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export class TextTrackStyleResetButton extends Button { + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; + private _player: ChromelessPlayer | undefined; get player(): ChromelessPlayer | undefined { @@ -34,7 +39,8 @@ export class TextTrackStyleResetButton extends Button { } protected override render(): HTMLTemplateResult { - return html`Reset`; + const locale = getLocale(this.lang); + return html`${locale.textTrackStyleResetLabel}`; } } diff --git a/src/components/TimeDisplay.ts b/src/components/TimeDisplay.ts index 1aebcf95..beaa0260 100644 --- a/src/components/TimeDisplay.ts +++ b/src/components/TimeDisplay.ts @@ -6,16 +6,15 @@ 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; -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]; @@ -27,8 +26,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 @@ -79,6 +78,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 +101,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; @@ -114,11 +117,11 @@ export class TimeDisplay extends LitElement { let ariaValueText: string; if (isNaN(this._duration)) { - ariaValueText = DEFAULT_MISSING_TIME_PHRASE; + ariaValueText = locale.unknownTimeAria; } else if (this.showDuration) { - ariaValueText = `${formatAsTimePhrase(time, remaining)} of ${formatAsTimePhrase(endTime)}`; + ariaValueText = locale.timeOfTotalAria(formatAsTimePhrase(locale, time, remaining), formatAsTimePhrase(locale, endTime)); } else { - ariaValueText = formatAsTimePhrase(time, remaining); + ariaValueText = formatAsTimePhrase(locale, time, remaining); } this.setAttribute('aria-valuetext', ariaValueText); diff --git a/src/components/TimeRange.ts b/src/components/TimeRange.ts index 16ba942b..10d5ad34 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'; @@ -23,7 +24,6 @@ import './PreviewTimeDisplay'; const UPDATE_EVENTS = ['timeupdate', 'durationchange', 'ratechange', 'seeking', 'seeked'] as const; const AUTO_ADVANCE_EVENTS = ['play', 'pause', 'ended', 'durationchange', 'readystatechange', 'error'] as const; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak', 'adbegin', 'adend', 'adskip', 'addad', 'updatead'] as const; -const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time'; /** * Width of an ad marker on the progress bar, in percent of the total bar width. @@ -42,7 +42,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]; @@ -118,6 +118,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,16 +185,17 @@ 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}`; + return locale.timeOfTotalAria(currentTimePhrase, totalTimePhrase); } - return DEFAULT_MISSING_TIME_PHRASE; + return locale.unknownTimeAria; } protected override handleInput(): void { diff --git a/src/components/VolumeRange.ts b/src/components/VolumeRange.ts index f6b030c0..f36c17b1 100644 --- a/src/components/VolumeRange.ts +++ b/src/components/VolumeRange.ts @@ -2,17 +2,15 @@ import { Range } from './Range'; import { customElement, property } from 'lit/decorators.js'; import { stateReceiver } from './StateReceiverMixin'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; - -function formatAsPercentString(value: number, max: number) { - return `${Math.round((value / max) * 100)}%`; -} +import { Attribute } from '../util/Attribute'; +import { getLocale } from '../i18n'; /** * A volume slider, showing the current audio volume of the player, * 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 +29,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,11 +59,11 @@ export class VolumeRange extends Range { }; protected override getAriaLabel(): string { - return 'volume'; + return getLocale(this.lang).volumeAria; } 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/components/ads/AdClickThroughButton.ts b/src/components/ads/AdClickThroughButton.ts index 91697265..c6ccd68c 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; @@ -13,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; @@ -52,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; } @@ -100,7 +104,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..da072fc4 100644 --- a/src/components/ads/AdCountdown.ts +++ b/src/components/ads/AdCountdown.ts @@ -4,6 +4,9 @@ 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'; +import { toDuration } from '../../util/TimeUtils'; +import { Attribute } from '../../util/Attribute'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak'] as const; @@ -11,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]; @@ -21,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; } @@ -61,7 +67,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.formatNarrowDuration(toDuration(this._maxRemainingDuration)); + return html`${locale.adCountdownText(remainingDuration)}`; } } diff --git a/src/components/ads/AdDisplay.ts b/src/components/ads/AdDisplay.ts index 21b64e3a..5c288146 100644 --- a/src/components/ads/AdDisplay.ts +++ b/src/components/ads/AdDisplay.ts @@ -6,6 +6,8 @@ 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'; +import { Attribute } from '../../util/Attribute'; const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak', 'adbegin', 'adend', 'adskip', 'addad', 'updatead'] as const; @@ -19,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]; @@ -27,7 +29,13 @@ 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; + + @property({ reflect: true, type: String, attribute: Attribute.LANG }) + accessor lang: string = ''; connectedCallback(): void { super.connectedCallback(); @@ -54,28 +62,31 @@ export class AdDisplay extends LitElement { 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 = `Ad ${currentAdIndex + 1} of ${linearAds.length}`; - this.style.display = ''; - return; + currentAd = currentAdIndex + 1; } } } - this._text = 'Ad'; + 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}`; } } diff --git a/src/components/ads/AdSkipButton.ts b/src/components/ads/AdSkipButton.ts index 0ce17479..a87649ed 100644 --- a/src/components/ads/AdSkipButton.ts +++ b/src/components/ads/AdSkipButton.ts @@ -10,6 +10,8 @@ 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'; +import { toDuration } from '../../util/TimeUtils'; const AD_EVENTS = ['adbegin', 'adend', 'adloaded', 'updatead', 'adskip'] as const; @@ -32,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]; @@ -45,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(); @@ -123,6 +128,7 @@ export class AdSkipButton extends Button { }; protected override render(): HTMLTemplateResult { + const locale = getLocale(this.lang); const countdownStyles = { visibility: this._showCountdown ? 'visible' : 'hidden' }; @@ -130,9 +136,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.formatNarrowDuration(toDuration(this._timeToSkip)); + return html`${locale.adSkipCountdownText(timeToSkip)} - Skip Ad + ${locale.adSkipButtonText} ${unsafeSVG(skipNextIcon)} `; } 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/AbstractQualitySelector.ts b/src/components/theolive/quality/AbstractQualitySelector.ts index 95947ab8..b33a078b 100644 --- a/src/components/theolive/quality/AbstractQualitySelector.ts +++ b/src/components/theolive/quality/AbstractQualitySelector.ts @@ -1,17 +1,22 @@ import type { ChromelessPlayer, TheoLiveApi } from 'theoplayer/chromeless'; import { stateReceiver } from '../../StateReceiverMixin'; import { RadioButton } from '../../RadioButton'; +import { property } from 'lit/decorators.js'; +import { Attribute } from '../../../util/Attribute'; /** * A radio button that shows the label of a given video quality, and switches the video track's * {@link theoplayer!MediaTrack.targetQuality | target quality} to that quality when clicked. */ -@stateReceiver(['player']) +@stateReceiver(['player', 'lang']) export abstract class AbstractQualitySelector extends RadioButton { private _player: ChromelessPlayer | undefined; private _theoLive: TheoLiveApi | undefined; protected _badNetworkMode: 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/theolive/quality/AutomaticQualitySelector.ts b/src/components/theolive/quality/AutomaticQualitySelector.ts index 8c5178ca..f2fa3bb7 100644 --- a/src/components/theolive/quality/AutomaticQualitySelector.ts +++ b/src/components/theolive/quality/AutomaticQualitySelector.ts @@ -1,6 +1,7 @@ import { AbstractQualitySelector } from './AbstractQualitySelector'; import { html, type HTMLTemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; +import { getLocale } from '../../../i18n'; @customElement('theolive-automatic-quality-selector') export class AutomaticQualitySelector extends AbstractQualitySelector { @@ -30,6 +31,7 @@ export class AutomaticQualitySelector extends AbstractQualitySelector { } protected override render(): HTMLTemplateResult { - return html`High Quality`; + const locale = getLocale(this.lang); + return html`${locale.highQualityLabel}`; } } diff --git a/src/components/theolive/quality/BadNetworkModeButton.ts b/src/components/theolive/quality/BadNetworkModeButton.ts index 5670bbf0..38705b51 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'; @@ -8,15 +8,17 @@ 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'; +import { getLocale } from '../../../i18n'; import { Attribute } from '../../../util/Attribute'; /** - * 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']) +@stateReceiver(['player', 'lang']) export class BadNetworkModeButton extends MenuButton { static styles = [...MenuButton.styles, badNetworkModeButtonCss]; @@ -26,14 +28,6 @@ export class BadNetworkModeButton extends MenuButton { @state() private accessor _inBadNetworkMode = false; - override connectedCallback() { - super.connectedCallback(); - - if (!this.hasAttribute(Attribute.ARIA_LABEL)) { - this.setAttribute(Attribute.ARIA_LABEL, 'open settings menu'); - } - } - private readonly handleEnterBadNetworkMode_ = () => { this._inBadNetworkMode = true; }; @@ -42,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; } @@ -63,6 +60,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/components/theolive/quality/BadNetworkModeSelector.ts b/src/components/theolive/quality/BadNetworkModeSelector.ts index cc181ac3..1d21f197 100644 --- a/src/components/theolive/quality/BadNetworkModeSelector.ts +++ b/src/components/theolive/quality/BadNetworkModeSelector.ts @@ -1,6 +1,7 @@ import { AbstractQualitySelector } from './AbstractQualitySelector'; import { html, type HTMLTemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; +import { getLocale } from '../../../i18n'; @customElement('theolive-bad-network-quality-selector') export class BadNetworkModeSelector extends AbstractQualitySelector { @@ -30,6 +31,7 @@ export class BadNetworkModeSelector extends AbstractQualitySelector { } protected override render(): HTMLTemplateResult { - return html`Low Quality`; + const locale = getLocale(this.lang); + return html`${locale.lowQualityLabel}`; } } 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'; diff --git a/src/i18n/BandwidthFormatter.ts b/src/i18n/BandwidthFormatter.ts new file mode 100644 index 00000000..633c5292 --- /dev/null +++ b/src/i18n/BandwidthFormatter.ts @@ -0,0 +1,42 @@ +export type BandwidthFormatter = (bandwidth: number) => string; + +export function bandwidthFormatterForLocale(locale: string): BandwidthFormatter { + try { + // Use Intl.Number (if supported). + const options: Intl.NumberFormatOptions = { + style: 'unit', + unitDisplay: 'narrow' + }; + const kbpsFormatter = new Intl.NumberFormat(locale, { + ...options, + unit: 'kilobit-per-second', + maximumFractionDigits: 0 + }); + const mbpsFormatter = new Intl.NumberFormat(locale, { + ...options, + unit: 'megabit-per-second', + minimumSignificantDigits: 2, + maximumFractionDigits: 1, + roundingPriority: 'lessPrecision' + }); + return (bandwidth) => { + if (bandwidth > 1e6) { + return mbpsFormatter.format(bandwidth / 1e6); + } else { + return kbpsFormatter.format(bandwidth / 1e3); + } + }; + } catch { + return defaultBandwidthFormatter; + } +} + +function defaultBandwidthFormatter(bandwidth: number): string { + if (bandwidth > 1e7) { + return `${(bandwidth / 1e6).toFixed(0)}Mb/s`; + } else if (bandwidth > 1e6) { + return `${(bandwidth / 1e6).toFixed(1)}Mb/s`; + } else { + return `${(bandwidth / 1e3).toFixed(0)}kb/s`; + } +} diff --git a/src/i18n/DurationFormatter.ts b/src/i18n/DurationFormatter.ts new file mode 100644 index 00000000..07502989 --- /dev/null +++ b/src/i18n/DurationFormatter.ts @@ -0,0 +1,50 @@ +import type { Duration } from './Locale'; + +export type DurationFormatter = (duration: Duration) => string; + +export function durationFormatterForLocale(locale: string, style: 'long' | 'narrow'): DurationFormatter { + try { + // Use Intl.DurationFormat (if supported). + 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 style === 'narrow' ? defaultNarrowFormatDuration : defaultFormatDuration; + } +} + +export function defaultFormatDuration({ hours, minutes, seconds }: Duration): string { + 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 { + 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; +} diff --git a/src/i18n/Locale.ts b/src/i18n/Locale.ts new file mode 100644 index 00000000..a8d8f533 --- /dev/null +++ b/src/i18n/Locale.ts @@ -0,0 +1,487 @@ +import { durationFormatterForLocale } from './DurationFormatter'; +import { percentageFormatterForLocale } from './PercentageFormatter'; +import { bandwidthFormatterForLocale } from './BandwidthFormatter'; +import type { EdgeStyle } from 'theoplayer/chromeless'; +import type { + ActiveQualityDisplay, + AdClickThroughButton, + AdCountdown, + AdDisplay, + AdSkipButton, + AirPlayButton, + AutomaticQualitySelector, + BadNetworkModeButton, + BadNetworkModeMenu, + BadNetworkModeSelector, + ChromecastButton, + CloseMenuButton, + ErrorDisplay, + FullscreenButton, + LanguageMenu, + LanguageMenuButton, + LiveButton, + MuteButton, + PlaybackRateDisplay, + PlaybackRateMenu, + PlaybackRateMenuButton, + PlayButton, + QualityRadioButton, + SeekButton, + SettingsMenu, + SettingsMenuButton, + TextTrackOffRadioButton, + TextTrackStyleDisplay, + TextTrackStyleMenu, + TextTrackStyleRadioGroup, + TextTrackStyleResetButton, + TimeDisplay, + TimeRange, + VolumeRange +} from '../components'; + +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 MuteButton} when it is showing a "mute" button. + */ + muteAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@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}. + */ + 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". + */ + live: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link LiveButton}. + */ + seekToLiveAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link FullscreenButton}. + */ + fullscreenAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link FullscreenButton} when in fullscreen mode. + */ + fullscreenExitAria: 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 a {@link ChromecastButton}. + */ + chromecastAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link ChromecastButton} when it is connected to Chromecast. + */ + chromecastConnectedAria: string; + /** + * The {@link HTMLElement.ariaValueText | `aria-valuetext`} for a {@link TimeDisplay} and {@link TimeRange} + * when it has both a valid time and duration to display. + * + * Examples: + * - "5 seconds" and "10 seconds" → "5 seconds of 10 seconds" + * + * @param time A time duration that was formatted with {@link formatDuration} or {@link formatRemainingDuration}. + * @param totalDuration A total duration that was formatted with {@link formatDuration} or {@link formatRemainingDuration}. + */ + timeOfTotalAria(time: string, totalDuration: string): string; + /** + * The {@link HTMLElement.ariaValueText | `aria-valuetext`} for a {@link TimeDisplay} and {@link TimeRange} + * when it does not have a valid time to display. + */ + unknownTimeAria: 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 formatNarrowDuration}. + */ + 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 formatNarrowDuration}. + */ + adSkipCountdownText(remainingDuration: string): string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link CloseMenuButton}. + */ + closeMenuAria: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@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 label for a {@link TextTrackOffRadioButton} to disable the active subtitle track. + */ + subtitleOff: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@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 a {@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 heading for a {@link TextTrackStyleMenu}. + */ + textTrackStyleMenuHeading: string; + /** + * The heading for the font family style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleFontFamily: string; + /** + * The heading for the font color style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleFontColor: string; + /** + * The heading for the font opacity style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleFontOpacity: string; + /** + * The heading for the font size style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleFontSize: string; + /** + * The heading for the background color style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleBackgroundColor: string; + /** + * The heading for the background opacity style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleBackgroundOpacity: string; + /** + * The heading for the window color style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleWindowColor: string; + /** + * The heading for the window opacity style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleWindowOpacity: string; + /** + * The heading for the edge style option in a {@link TextTrackStyleMenu}. + */ + textTrackStyleEdgeStyle: string; + /** + * The label for the default style option in a {@link TextTrackStyleRadioGroup}. + */ + textTrackStyleDefaultLabel: string; + /** + * The label for a {@link TextTrackStyleDisplay} when it is showing a style option + * that does not match any predefined values. + */ + textTrackStyleCustomLabel: string; + /** + * The label for a {@link TextTrackStyleResetButton} to reset the text track style. + */ + textTrackStyleResetLabel: string; + /** + * The labels for font family style options in a {@link TextTrackStyleRadioGroup}, + * keyed by the original English label (e.g. "Default" or "Monospace Serif"). + */ + fontFamilyLabels: Record; + /** + * The labels for color style options in a {@link TextTrackStyleRadioGroup}, + * keyed by the original English label (e.g. "White", "Black" or "Red"). + */ + colorLabels: Record; + /** + * The labels for edge style options in a {@link TextTrackStyleRadioGroup}, + * keyed by an {@link EdgeStyle} value. + */ + edgeStyleLabels: Record; + /** + * The label for an {@link ActiveQualityDisplay} or {@link QualityRadioButton} when it is showing the "Automatic" quality selection option. + */ + automaticQualityLabel: string; + /** + * The label for a {@link ActiveQualityDisplay} when it is showing a quality selection option + * without any usable label. + */ + unknownQualityLabel: string; + /** + * The label for an {@link AutomaticQualitySelector} for THEOlive's {@link BadNetworkModeMenu}. + */ + highQualityLabel: string; + /** + * The label for a {@link BadNetworkModeSelector} for THEOlive's {@link BadNetworkModeMenu}. + */ + lowQualityLabel: string; + /** + * The heading for an {@link ErrorDisplay}. + */ + errorHeading: string; + /** + * The {@link HTMLElement.ariaLabel | `aria-label`} for a {@link BadNetworkModeButton}. + */ + openBadNetworkModeMenuAria: 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 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. + * + * 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; + /** + * 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; + /** + * Formats a bandwidth value. + * + * This is optional. If not provided, a default {@link Intl.NumberFormat} with the {@link Intl.NumberFormatOptions.style | `"unit"` style} is used + * that dynamically picks between the `"kilobit-per-second"` or `"megabit-per-second"` {@link Intl.NumberFormatOptions.unit | unit}s. + * + * Examples: + * - `150_000` → "150kb/s" + * - `2_500_000` → "2.5Mb/s" + * + * @param bandwidth A bandwidth value, in bits per second. + */ + formatBandwidth(bandwidth: number): string; +} + +/** + * A duration, compatible with {@link Intl.DurationFormat.format} and {@link Temporal.Duration}. + */ +export interface Duration { + hours: number; + minutes: number; + seconds: number; +} + +/** + * The known font families for a {@link TextTrackStyleRadioGroup}. + */ +export type KnownFontFamily = 'Monospace Serif' | 'Proportional Serif' | 'Monospace Sans' | 'Proportional Sans'; + +/** + * The known colors for a {@link TextTrackStyleRadioGroup}. + */ +export type KnownColor = 'White' | 'Yellow' | 'Green' | 'Cyan' | 'Blue' | 'Magenta' | 'Red' | 'Black'; + +/** + * A partial {@link Locale}. + */ +export type PartialLocale = Partial & { + fontFamilyLabels?: Partial; + colorLabels?: Partial; + edgeStyleLabels?: Partial; +}; + +export const defaultLocaleName = 'en'; +export const defaultLocale: Locale = { + playAria: 'play', + pauseAria: 'pause', + replayAria: 'replay', + muteAria: 'mute', + unmuteAria: 'unmute', + volumeAria: 'volume', + seekAria: 'seek', + seekForwardAria: (offset) => `seek forward by ${offset}`, + seekBackwardAria: (offset) => `seek backward by ${offset}`, + 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', + chromecastConnectedAria: 'stop casting to Chromecast', + timeOfTotalAria: (currentTime: string, totalDuration: string) => `${currentTime} of ${totalDuration}`, + unknownTimeAria: 'video not loaded, unknown time', + 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', + languageMenuHeading: 'Language', + audioMenuHeading: 'Audio', + subtitleMenuHeading: 'Subtitles', + subtitleOff: 'Off', + openPlaybackRateMenuAria: 'open playback speed menu', + playbackRateMenuHeading: 'Playback speed', + formatPlaybackRate: (rate: number) => (rate === 1 ? 'Normal' : `${rate}x`), + openSettingsMenuAria: 'open settings menu', + settingsMenuHeading: 'Settings', + qualityMenuHeading: 'Quality', + textTrackStyleMenuHeading: 'Subtitle options', + textTrackStyleFontFamily: 'Font family', + textTrackStyleFontColor: 'Font color', + textTrackStyleFontOpacity: 'Font opacity', + textTrackStyleFontSize: 'Font size', + textTrackStyleBackgroundColor: 'Background color', + textTrackStyleBackgroundOpacity: 'Background opacity', + textTrackStyleWindowColor: 'Window color', + textTrackStyleWindowOpacity: 'Window opacity', + textTrackStyleEdgeStyle: 'Character edge style', + textTrackStyleDefaultLabel: 'Default', + textTrackStyleCustomLabel: 'Custom', + textTrackStyleResetLabel: 'Reset', + fontFamilyLabels: { + 'Monospace Serif': 'Monospace Serif', + 'Proportional Serif': 'Proportional Serif', + 'Monospace Sans': 'Monospace Sans', + 'Proportional Sans': 'Proportional Sans' + }, + colorLabels: { + White: 'White', + Yellow: 'Yellow', + Green: 'Green', + Cyan: 'Cyan', + Blue: 'Blue', + Magenta: 'Magenta', + Red: 'Red', + Black: 'Black' + }, + edgeStyleLabels: { + none: 'None', + dropshadow: 'Drop shadow', + raised: 'Raised', + depressed: 'Depressed', + uniform: 'Uniform' + }, + automaticQualityLabel: 'Automatic', + unknownQualityLabel: 'Unknown', + highQualityLabel: 'High Quality', + lowQualityLabel: 'Low Quality', + errorHeading: 'An error occurred', + openBadNetworkModeMenuAria: 'open bad network mode menu', + formatDuration: durationFormatterForLocale(defaultLocaleName, 'long'), + formatNarrowDuration: durationFormatterForLocale(defaultLocaleName, 'narrow'), + formatRemainingDuration: (duration: string) => `${duration} remaining`, + formatPercentage: percentageFormatterForLocale(defaultLocaleName), + formatBandwidth: bandwidthFormatterForLocale(defaultLocaleName) +}; diff --git a/src/i18n/LocaleRegistry.ts b/src/i18n/LocaleRegistry.ts new file mode 100644 index 00000000..9ee6fc9c --- /dev/null +++ b/src/i18n/LocaleRegistry.ts @@ -0,0 +1,44 @@ +import { defaultLocale, type Locale, type PartialLocale } from './Locale'; +import { durationFormatterForLocale } from './DurationFormatter'; +import { percentageFormatterForLocale } from './PercentageFormatter'; +import { bandwidthFormatterForLocale } from './BandwidthFormatter'; + +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: PartialLocale) { + // TODO Re-render all components that use this locale? + localesByName[name] = { + ...defaultLocale, + ...locale, + fontFamilyLabels: { + ...defaultLocale.fontFamilyLabels, + ...locale.fontFamilyLabels + }, + colorLabels: { + ...defaultLocale.colorLabels, + ...locale.colorLabels + }, + edgeStyleLabels: { + ...defaultLocale.edgeStyleLabels, + ...locale.edgeStyleLabels + }, + formatDuration: locale.formatDuration ?? durationFormatterForLocale(name, 'long'), + formatNarrowDuration: locale.formatNarrowDuration ?? durationFormatterForLocale(name, 'narrow'), + formatPercentage: locale.formatPercentage ?? percentageFormatterForLocale(name), + formatBandwidth: locale.formatBandwidth ?? bandwidthFormatterForLocale(name) + }; +} diff --git a/src/i18n/PercentageFormatter.ts b/src/i18n/PercentageFormatter.ts new file mode 100644 index 00000000..1235f808 --- /dev/null +++ b/src/i18n/PercentageFormatter.ts @@ -0,0 +1,16 @@ +export type PercentageFormatter = (percentage: number) => string; + +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/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..b76658f4 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,4 @@ +export { type Locale, type Duration, type KnownColor, type KnownFontFamily, type PartialLocale } from './Locale'; +export { type DurationFormatter } from './DurationFormatter'; +export { type PercentageFormatter } from './PercentageFormatter'; +export { getLocale, addLocale } from './LocaleRegistry'; diff --git a/src/index.ts b/src/index.ts index 9459521d..2cb30bf2 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, type PartialLocale, type Duration, type KnownColor, type KnownFontFamily, 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..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; } @@ -259,3 +271,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; +} diff --git a/src/util/TextTrackStylePresets.ts b/src/util/TextTrackStylePresets.ts index c577951d..be847f4f 100644 --- a/src/util/TextTrackStylePresets.ts +++ b/src/util/TextTrackStylePresets.ts @@ -1,27 +1,22 @@ import type { EdgeStyle } from 'theoplayer/chromeless'; +import type { KnownColor, KnownFontFamily } from '../i18n'; -export const knownColors: ReadonlyArray<[string, string]> = [ - ['White', 'rgb(255,255,255)'], - ['Yellow', 'rgb(255,255,0)'], - ['Green', 'rgb(0,255,0)'], - ['Cyan', 'rgb(0,255,255)'], - ['Blue', 'rgb(0,0,255)'], - ['Magenta', 'rgb(255,0,255)'], - ['Red', 'rgb(255,0,0)'], - ['Black', 'rgb(0,0,0)'] +export const colorOptions: ReadonlyArray<{ label: KnownColor; value: `rgb(${number},${number},${number})` | '' }> = [ + { label: 'White', value: 'rgb(255,255,255)' }, + { label: 'Yellow', value: 'rgb(255,255,0)' }, + { label: 'Green', value: 'rgb(0,255,0)' }, + { label: 'Cyan', value: 'rgb(0,255,255)' }, + { label: 'Blue', value: 'rgb(0,0,255)' }, + { label: 'Magenta', value: 'rgb(255,0,255)' }, + { label: 'Red', value: 'rgb(255,0,0)' }, + { label: 'Black', value: 'rgb(0,0,0)' } ]; - -export const knownFontFamilies: ReadonlyArray<[string, string]> = [ - ['Monospace Serif', '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace'], - ['Proportional Serif', '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif'], - ['Monospace Sans', '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace'], - ['Proportional Sans', 'Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif'] -]; - -export const knownEdgeStyles: ReadonlyArray<[string, EdgeStyle]> = [ - ['None', 'none'], - ['Drop shadow', 'dropshadow'], - ['Raised', 'raised'], - ['Depressed', 'depressed'], - ['Uniform', 'uniform'] +export const fontFamilyOptions: ReadonlyArray<{ label: KnownFontFamily; value: string }> = [ + { label: 'Monospace Serif', value: '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace' }, + { label: 'Proportional Serif', value: '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif' }, + { label: 'Monospace Sans', value: '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace' }, + { label: 'Proportional Sans', value: 'Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif' } ]; +export const sizeOptions: ReadonlyArray = [0.5, 0.75, 1.0, 1.5, 2.0]; +export const opacityOptions: ReadonlyArray = [0.25, 0.5, 0.75, 1.0]; +export const edgeStyleOptions: ReadonlyArray = ['none', 'dropshadow', 'raised', 'depressed', 'uniform']; diff --git a/src/util/TimeUtils.ts b/src/util/TimeUtils.ts index 6fa4f20a..74d6874d 100644 --- a/src/util/TimeUtils.ts +++ b/src/util/TimeUtils.ts @@ -1,3 +1,5 @@ +import { type Duration, type Locale } from '../i18n'; + function isValidNumber(x: number): boolean { return !isNaN(x) && isFinite(x); } @@ -33,36 +35,19 @@ 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 toDuration(seconds: number): Duration { + return { + hours: Math.floor(seconds / 3600), + minutes: Math.floor(seconds / 60) % 60, + seconds: Math.floor(seconds % 60) + }; } -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); - - const seconds = Math.floor(time % 60); - 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(toDuration(Math.abs(time))); // 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; } diff --git a/src/util/TrackUtils.ts b/src/util/TrackUtils.ts index 1010dcf8..89319def 100644 --- a/src/util/TrackUtils.ts +++ b/src/util/TrackUtils.ts @@ -1,4 +1,5 @@ import type { MediaTrack, Quality, TextTrack, VideoQuality } from 'theoplayer/chromeless'; +import type { Locale } from '../i18n'; export function isSubtitleTrack(track: TextTrack): boolean { return track.kind === 'subtitles' || track.kind === 'captions'; @@ -16,7 +17,7 @@ export function getTargetQualities(videoTrack: MediaTrack | undefined): Quality[ return targetQualities; } -export function formatQualityLabel(quality: VideoQuality | undefined): string | undefined { +export function formatQualityLabel(locale: Locale, quality: VideoQuality | undefined): string | undefined { if (!quality) { return undefined; } @@ -27,19 +28,7 @@ export function formatQualityLabel(quality: VideoQuality | undefined): string | return `${quality.height}p`; } if (quality.bandwidth) { - return formatBandwidth(quality); + return locale.formatBandwidth(quality.bandwidth); } return undefined; } - -function formatBandwidth(quality: VideoQuality): string | undefined { - if (!quality.bandwidth) { - return undefined; - } else if (quality.bandwidth > 1e7) { - return `${(quality.bandwidth / 1e6).toFixed(0)}Mbps`; - } else if (quality.bandwidth > 1e6) { - return `${(quality.bandwidth / 1e6).toFixed(1)}Mbps`; - } else { - return `${(quality.bandwidth / 1e3).toFixed(0)}kbps`; - } -} diff --git a/tsconfig.json b/tsconfig.json index 1ddf70b6..fb53d767 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", "es2023.intl", "es2025.intl", "esnext.temporal"], "types": [] }, "include": ["src/**/*"] diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 16b82f91..c0ee82a8 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -28,6 +28,16 @@ export default { } }, externalSymbolLinkMappings: { + typescript: { + 'ARIAMixin.ariaLabel': 'https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label', + 'ARIAMixin.ariaValueText': 'https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuetext', + 'Intl.DurationFormatStyle': + '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', + 'Intl.NumberFormatOptions.unit': + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#unit_2' + }, 'lit-element': { LitElement: 'https://lit.dev/docs/api/LitElement/', '*': 'https://lit.dev/docs/'