diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ea1242..42781ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ > - 🏠 Internal > - 💅 Polish +## Unreleased + +- 🚀 Added support for smart TVs. ([#40](https://github.com/THEOplayer/web-ui/pull/40)) + - Updated `` to automatically switch to an optimized layout when running on a smart TV. + For custom UIs using ``, you can use the `tv-only` and `tv-hidden` attributes to show or hide specific UI elements on smart TVs. + - Added support for navigating the UI using a TV remote control. + - Added a `tv-focus` attribute to specify which UI element should receive the initial focus when showing the controls on a TV. + In the default UI, initial focus is on the seek bar. + ## v1.4.0 (2023-10-04) - 💥 **Breaking Change**: This project now requires THEOplayer version 6.0.0 or higher. diff --git a/docs/examples/custom-ui.html b/docs/examples/custom-ui.html index 4a7d0537..fa4268a7 100644 --- a/docs/examples/custom-ui.html +++ b/docs/examples/custom-ui.html @@ -106,10 +106,6 @@ diff --git a/docs/examples/default-ui.html b/docs/examples/default-ui.html index 57fe8a62..b94b5ee6 100644 --- a/docs/examples/default-ui.html +++ b/docs/examples/default-ui.html @@ -19,15 +19,22 @@ Elephant's Dream

- +

diff --git a/src/DefaultUI.css b/src/DefaultUI.css index 6801c85b..f6f93e9d 100644 --- a/src/DefaultUI.css +++ b/src/DefaultUI.css @@ -160,6 +160,16 @@ theoplayer-ad-skip-button:not([disabled]) { display: none !important; } +/* + * TV-only and TV-hidden elements + */ +:host([tv]) [tv-hidden], +:host([tv]) theoplayer-control-bar ::slotted([tv-hidden]), +:host(:not([tv])) [tv-only], +:host(:not([tv])) theoplayer-control-bar ::slotted([tv-only]) { + display: none !important; +} + /* * Live-only and live-hidden elements */ diff --git a/src/DefaultUI.html b/src/DefaultUI.html index a8a68983..418ee10b 100644 --- a/src/DefaultUI.html +++ b/src/DefaultUI.html @@ -29,20 +29,20 @@ - + - - + + - - + + - + diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index a1eb5002..bb8e923c 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -5,10 +5,12 @@ import defaultUiCss from './DefaultUI.css'; import defaultUiHtml from './DefaultUI.html'; import { Attribute } from './util/Attribute'; import { applyExtensions } from './extensions/ExtensionRegistry'; -import { isMobile } from './util/Environment'; +import { isMobile, isTv } from './util/Environment'; +import type { DeviceType } from './util/DeviceType'; import type { StreamType } from './util/StreamType'; import type { TimeRange } from './components/TimeRange'; import { STREAM_TYPE_CHANGE_EVENT } from './events/StreamTypeChangeEvent'; +import { toggleAttribute } from './util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = `${defaultUiHtml}`; @@ -40,8 +42,10 @@ shadyCss.prepareTemplate(template, 'theoplayer-default-ui'); * @attribute `fluid` - If set, the player automatically adjusts its height to fit the video's aspect ratio. * @attribute `muted` - If set, the player starts out as muted. Reflects `ui.player.muted`. * @attribute `autoplay` - If set, the player attempts to automatically start playing (if allowed). - * @attribute `mobile` - Whether to use a mobile-optimized UI layout instead. - * Can be used in CSS to show/hide certain desktop-specific or mobile-specific UI controls. + * @attribute `device-type` - The device type, either "desktop", "mobile" or "tv". + * Can be used in CSS to show/hide certain device-specific UI controls. + * @attribute `mobile` - Whether the user is on a mobile device. Equivalent to `device-type == "mobile"`. + * @attribute `tv` - Whether the user is on a TV device. Equivalent to `device-type == "tv"`. * @attribute `stream-type` - The stream type, either "vod", "live" or "dvr". * Can be used to show/hide certain UI controls specific for livestreams, such as * a {@link LiveButton | ``}. @@ -65,7 +69,7 @@ export class DefaultUI extends HTMLElement { Attribute.MUTED, Attribute.AUTOPLAY, Attribute.FLUID, - Attribute.MOBILE, + Attribute.DEVICE_TYPE, Attribute.STREAM_TYPE, Attribute.USER_IDLE_TIMEOUT, Attribute.DVR_THRESHOLD, @@ -227,8 +231,9 @@ export class DefaultUI extends HTMLElement { connectedCallback(): void { shadyCss.styleElement(this); - if (!this.hasAttribute(Attribute.MOBILE) && isMobile()) { - this.setAttribute(Attribute.MOBILE, ''); + if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { + const deviceType: DeviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); } if (!this._appliedExtensions) { @@ -258,17 +263,11 @@ export class DefaultUI extends HTMLElement { } else if (attrName === Attribute.AUTOPLAY) { this.autoplay = hasValue; } else if (attrName === Attribute.FLUID) { - if (hasValue) { - this._ui.setAttribute(Attribute.FLUID, newValue); - } else { - this._ui.removeAttribute(Attribute.FLUID); - } - } else if (attrName === Attribute.MOBILE) { - if (hasValue) { - this._ui.setAttribute(Attribute.MOBILE, newValue); - } else { - this._ui.removeAttribute(Attribute.MOBILE); - } + toggleAttribute(this._ui, Attribute.FLUID, hasValue); + } else if (attrName === Attribute.DEVICE_TYPE) { + toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); + toggleAttribute(this, Attribute.TV, newValue === 'tv'); + this._ui.setAttribute(Attribute.DEVICE_TYPE, newValue); } else if (attrName === Attribute.STREAM_TYPE) { this.streamType = newValue; } else if (attrName === Attribute.USER_IDLE_TIMEOUT) { @@ -284,19 +283,11 @@ export class DefaultUI extends HTMLElement { private readonly _updateStreamType = () => { this.setAttribute(Attribute.STREAM_TYPE, this.streamType); // Hide seekbar when stream is live with no DVR - if (this.streamType === 'live') { - this._timeRange.setAttribute(Attribute.HIDDEN, ''); - } else { - this._timeRange.removeAttribute(Attribute.HIDDEN); - } + toggleAttribute(this._timeRange, Attribute.HIDDEN, this.streamType === 'live'); }; private readonly _onTitleSlotChange = () => { - if (this._titleSlot.assignedNodes().length > 0) { - this.setAttribute(Attribute.HAS_TITLE, ''); - } else { - this.removeAttribute(Attribute.HAS_TITLE); - } + toggleAttribute(this, Attribute.HAS_TITLE, this._titleSlot.assignedNodes().length > 0); }; } diff --git a/src/UIContainer.css b/src/UIContainer.css index 5f5a912d..472c8821 100644 --- a/src/UIContainer.css +++ b/src/UIContainer.css @@ -107,7 +107,7 @@ pointer-events: none; } -:host(:not([mobile])) [part~='menu-layer'] { +:host(:not([mobile]):not([tv])) [part~='menu-layer'] { top: var(--theoplayer-menu-offset-top, 0); bottom: var(--theoplayer-menu-offset-bottom, 0); padding: var(--theoplayer-menu-layer-padding, 10px); @@ -153,7 +153,16 @@ @mixin menu-fill-styles; } +:host([tv]) [part~='menu-layer'] { + left: auto; +} + +:host([tv]) [part='menu'] { + @mixin menu-fill-styles; +} + :host(:not([menu-opened])) [part~='menu-layer'], +:host([menu-opened][tv]) [part~='vertical-layer'], :host([menu-opened][mobile]) [part~='vertical-layer'] { display: none !important; } @@ -202,6 +211,14 @@ theoplayer-gesture-receiver { display: none !important; } +/* + * TV-only and TV-hidden elements + */ +:host([tv]) [part~='chrome'] ::slotted([tv-hidden]), +:host(:not([tv])) [part~='chrome'] ::slotted([tv-only]) { + display: none !important; +} + /* * Live-only and live-hidden elements */ diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 45f112b4..b071fc56 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -2,7 +2,17 @@ import * as shadyCss from '@webcomponents/shadycss'; import { ChromelessPlayer, type MediaTrack, type PlayerConfiguration, type SourceDescription, type VideoQuality } from 'theoplayer/chromeless'; import elementCss from './UIContainer.css'; import elementHtml from './UIContainer.html'; -import { arrayFind, arrayRemove, containsComposedNode, isElement, isHTMLElement, noOp } from './util/CommonUtils'; +import { + arrayFind, + arrayRemove, + containsComposedNode, + getFocusableChildren, + getTvFocusChildren, + isElement, + isHTMLElement, + noOp, + toggleAttribute +} from './util/CommonUtils'; import { forEachStateReceiverElement, type StateReceiverElement, StateReceiverProps } from './components/StateReceiverMixin'; import { TOGGLE_MENU_EVENT, type ToggleMenuEvent } from './events/ToggleMenuEvent'; import { CLOSE_MENU_EVENT } from './events/CloseMenuEvent'; @@ -10,7 +20,7 @@ import { ENTER_FULLSCREEN_EVENT, type EnterFullscreenEvent } from './events/Ente import { EXIT_FULLSCREEN_EVENT, type ExitFullscreenEvent } from './events/ExitFullscreenEvent'; import { fullscreenAPI } from './util/FullscreenUtils'; import { Attribute } from './util/Attribute'; -import { isMobile } from './util/Environment'; +import { isMobile, isTv } from './util/Environment'; import { Rectangle } from './util/GeometryUtils'; import './components/GestureReceiver'; import { PREVIEW_TIME_CHANGE_EVENT, type PreviewTimeChangeEvent } from './events/PreviewTimeChangeEvent'; @@ -22,12 +32,16 @@ import { getTargetQualities } from './util/TrackUtils'; import type { MenuGroup } from './components'; import './components/MenuGroup'; import { MENU_CHANGE_EVENT } from './events/MenuChangeEvent'; +import type { DeviceType } from './util/DeviceType'; +import { getFocusedChild, navigateByArrowKey } from './util/KeyboardNavigation'; +import { isArrowKey, isBackKey, KeyCode } from './util/KeyCode'; const template = document.createElement('template'); template.innerHTML = `${elementHtml}`; shadyCss.prepareTemplate(template, 'theoplayer-ui'); const DEFAULT_USER_IDLE_TIMEOUT = 2; +const DEFAULT_TV_USER_IDLE_TIMEOUT = 5; const DEFAULT_DVR_THRESHOLD = 60; /** @@ -57,8 +71,10 @@ const DEFAULT_DVR_THRESHOLD = 60; * @attribute `fluid` - If set, the player automatically adjusts its height to fit the video's aspect ratio. * @attribute `muted` - If set, the player starts out as muted. Reflects `ui.player.muted`. * @attribute `autoplay` - If set, the player attempts to automatically start playing (if allowed). - * @attribute `mobile` - Whether to use a mobile-optimized UI layout instead. - * Can be used in CSS to show/hide certain desktop-specific or mobile-specific UI controls. + * @attribute `device-type` - The device type, either "desktop", "mobile" or "tv". + * Can be used in CSS to show/hide certain device-specific UI controls. + * @attribute `mobile` - Whether the user is on a mobile device. Equivalent to `device-type == "mobile"`. + * @attribute `tv` - Whether the user is on a TV device. Equivalent to `device-type == "tv"`. * @attribute `stream-type` - The stream type, either "vod", "live" or "dvr". * Can be used to show/hide certain UI controls specific for livestreams, such as * a {@link LiveButton | ``}. @@ -102,7 +118,7 @@ export class UIContainer extends HTMLElement { Attribute.AUTOPLAY, Attribute.FULLSCREEN, Attribute.FLUID, - Attribute.MOBILE, + Attribute.DEVICE_TYPE, Attribute.PAUSED, Attribute.ENDED, Attribute.CASTING, @@ -249,11 +265,7 @@ export class UIContainer extends HTMLElement { } set muted(value: boolean) { - if (value) { - this.setAttribute(Attribute.MUTED, ''); - } else { - this.removeAttribute(Attribute.MUTED); - } + toggleAttribute(this, Attribute.MUTED, value); } /** @@ -264,11 +276,7 @@ export class UIContainer extends HTMLElement { } set autoplay(value: boolean) { - if (value) { - this.setAttribute(Attribute.AUTOPLAY, ''); - } else { - this.removeAttribute(Attribute.AUTOPLAY); - } + toggleAttribute(this, Attribute.AUTOPLAY, value); } /** @@ -304,7 +312,8 @@ export class UIContainer extends HTMLElement { * and when the user is considered to be "idle". */ get userIdleTimeout(): number { - return Number(this.getAttribute(Attribute.USER_IDLE_TIMEOUT) ?? DEFAULT_USER_IDLE_TIMEOUT); + const defaultTimeout = this.deviceType === 'tv' ? DEFAULT_TV_USER_IDLE_TIMEOUT : DEFAULT_USER_IDLE_TIMEOUT; + return Number(this.getAttribute(Attribute.USER_IDLE_TIMEOUT) ?? defaultTimeout); } set userIdleTimeout(value: number) { @@ -312,6 +321,13 @@ export class UIContainer extends HTMLElement { this.setAttribute(Attribute.USER_IDLE_TIMEOUT, String(isNaN(value) ? 0 : value)); } + /** + * The device type, either "desktop", "mobile" or "tv". + */ + get deviceType(): DeviceType { + return (this.getAttribute(Attribute.DEVICE_TYPE) as DeviceType) || 'desktop'; + } + /** * The stream type, either "vod", "live" or "dvr". * @@ -342,8 +358,9 @@ export class UIContainer extends HTMLElement { connectedCallback(): void { shadyCss.styleElement(this); - if (!this.hasAttribute(Attribute.MOBILE) && isMobile()) { - this.setAttribute(Attribute.MOBILE, ''); + if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { + const deviceType: DeviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); } if (!this.hasAttribute(Attribute.PAUSED)) { this.setAttribute(Attribute.PAUSED, ''); @@ -371,6 +388,9 @@ export class UIContainer extends HTMLElement { } this.setUserIdle_(); + if (this.deviceType === 'tv') { + window.addEventListener('keydown', this._onTvKeyDown); + } this.addEventListener('keyup', this._onKeyUp); this.addEventListener('pointerup', this._onPointerUp); this.addEventListener('pointermove', this._onPointerMove); @@ -435,6 +455,7 @@ export class UIContainer extends HTMLElement { document.removeEventListener(fullscreenAPI.fullscreenerror_, this._onFullscreenChange); } + window.removeEventListener('keydown', this._onTvKeyDown); this.removeEventListener('keyup', this._onKeyUp); this.removeEventListener('pointerup', this._onPointerUp); this.removeEventListener('click', this._onClickAfterPointerUp, true); @@ -470,6 +491,18 @@ export class UIContainer extends HTMLElement { receiver.fullscreen = hasValue; } } + } else if (attrName === Attribute.DEVICE_TYPE) { + toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); + toggleAttribute(this, Attribute.TV, newValue === 'tv'); + window.removeEventListener('keydown', this._onTvKeyDown); + if (newValue === 'tv') { + window.addEventListener('keydown', this._onTvKeyDown); + } + for (const receiver of this._stateReceivers) { + if (receiver[StateReceiverProps].indexOf('deviceType') >= 0) { + receiver.deviceType = newValue; + } + } } else if (attrName === Attribute.STREAM_TYPE) { for (const receiver of this._stateReceivers) { if (receiver[StateReceiverProps].indexOf('streamType') >= 0) { @@ -542,6 +575,9 @@ export class UIContainer extends HTMLElement { if (receiverProps.indexOf('fullscreen') >= 0) { receiver.fullscreen = this.fullscreen; } + if (receiverProps.indexOf('deviceType') >= 0) { + receiver.deviceType = this.deviceType; + } if (receiverProps.indexOf('streamType') >= 0) { receiver.streamType = this.streamType; } @@ -700,11 +736,7 @@ export class UIContainer extends HTMLElement { if (!isFullscreen && this._player !== undefined && this._player.presentation.currentMode === 'fullscreen') { isFullscreen = true; } - if (isFullscreen) { - this.setAttribute(Attribute.FULLSCREEN, ''); - } else { - this.removeAttribute(Attribute.FULLSCREEN); - } + toggleAttribute(this, Attribute.FULLSCREEN, isFullscreen); }; private readonly _updateAspectRatio = (): void => { @@ -727,11 +759,7 @@ export class UIContainer extends HTMLElement { private readonly _updateError = (): void => { const error = this._player?.errorObject; - if (error) { - this.setAttribute(Attribute.HAS_ERROR, ''); - } else { - this.removeAttribute(Attribute.HAS_ERROR); - } + toggleAttribute(this, Attribute.HAS_ERROR, error !== undefined); for (const receiver of this._stateReceivers) { if (receiver[StateReceiverProps].indexOf('error') >= 0) { receiver.error = error; @@ -752,21 +780,13 @@ export class UIContainer extends HTMLElement { private readonly _updatePausedAndEnded = (): void => { const paused = this._player ? this._player.paused : true; - if (paused) { - this.setAttribute(Attribute.PAUSED, ''); - } else { - this.removeAttribute(Attribute.PAUSED); - } + toggleAttribute(this, Attribute.PAUSED, paused); this._updateEnded(); }; private readonly _updateEnded = (): void => { const ended = this._player ? this._player.ended : false; - if (ended) { - this.setAttribute(Attribute.ENDED, ''); - } else { - this.removeAttribute(Attribute.ENDED); - } + toggleAttribute(this, Attribute.ENDED, ended); }; private readonly _updateStreamType = (): void => { @@ -846,29 +866,18 @@ export class UIContainer extends HTMLElement { private readonly _updateCasting = (): void => { const casting = this._player?.cast?.casting ?? false; - if (casting) { - this.setAttribute(Attribute.CASTING, ''); - } else { - this.removeAttribute(Attribute.CASTING); - } + toggleAttribute(this, Attribute.CASTING, casting); }; private readonly _updatePlayingAd = (): void => { const playingAd = this._player?.ads?.playing ?? false; - if (playingAd) { - this.setAttribute(Attribute.PLAYING_AD, ''); - } else { - this.removeAttribute(Attribute.PLAYING_AD); - } + toggleAttribute(this, Attribute.PLAYING_AD, playingAd); }; private readonly _onSourceChange = (): void => { this.closeMenu_(); - if (this._player !== undefined && !this._player.paused) { - this.setAttribute(Attribute.HAS_FIRST_PLAY, ''); - } else { - this.removeAttribute(Attribute.HAS_FIRST_PLAY); - } + const isPlaying = this._player !== undefined && !this._player.paused; + toggleAttribute(this, Attribute.HAS_FIRST_PLAY, isPlaying); }; private isUserIdle_(): boolean { @@ -888,8 +897,15 @@ export class UIContainer extends HTMLElement { if (this.userIdleTimeout < 0) { return; } - this.setAttribute(Attribute.USER_IDLE, ''); + + if (this.deviceType == 'tv' && this.isUserIdle_()) { + // Blur active element so that first key press on TV doesn't result in an action. + const focusedChild = getFocusedChild(); + if (focusedChild !== null) { + focusedChild.blur(); + } + } }; private readonly scheduleUserIdle_ = (): void => { @@ -910,9 +926,41 @@ export class UIContainer extends HTMLElement { return node === this || this._playerEl.contains(node); } - private readonly _onKeyUp = (): void => { + private readonly _onTvKeyDown = (event: KeyboardEvent): void => { + if (isBackKey(event.keyCode)) { + this.setUserIdle_(); + return; + } + const tvFocusChildren = getTvFocusChildren(this); + const focusableChildren = getFocusableChildren(this); + let focusedChild = getFocusedChild(); + if (!focusedChild) { + const children = tvFocusChildren ?? focusableChildren; + if (children.length > 0) { + children[0].focus(); + focusedChild = children[0]; + } + } + + if (this.isUserIdle_()) { + // First button press should only make the UI visible + return; + } + if (event.keyCode === KeyCode.ENTER) { + if (this._player !== undefined && focusedChild !== null) { + focusedChild.click(); + } + } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, focusableChildren, event.keyCode)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + private readonly _onKeyUp = (event: KeyboardEvent): void => { // Show the controls while navigating with the keyboard. - this.scheduleUserIdle_(); + if (!isBackKey(event.keyCode)) { + this.scheduleUserIdle_(); + } }; private readonly _onPointerUp = (event: PointerEvent): void => { diff --git a/src/components/Button.ts b/src/components/Button.ts index 41720b17..af53a55c 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -2,6 +2,7 @@ import * as shadyCss from '@webcomponents/shadycss'; import buttonCss from './Button.css'; import { KeyCode } from '../util/KeyCode'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; export interface ButtonOptions { template: HTMLTemplateElement; @@ -68,12 +69,10 @@ export class Button extends HTMLElement { this.setAttribute(Attribute.ARIA_LIVE, 'polite'); } - this.addEventListener('keydown', this._onKeyDown); this.addEventListener('click', this._onClick); } disconnectedCallback(): void { - this.removeEventListener('keydown', this._onKeyDown); this.removeEventListener('click', this._onClick); } @@ -87,11 +86,7 @@ export class Button extends HTMLElement { } set disabled(disabled: boolean) { - if (disabled) { - this.setAttribute(Attribute.DISABLED, ''); - } else { - this.removeAttribute(Attribute.DISABLED); - } + toggleAttribute(this, Attribute.DISABLED, disabled); } attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { @@ -114,22 +109,6 @@ export class Button extends HTMLElement { } } - private readonly _onKeyDown = (event: KeyboardEvent) => { - // Don't handle modifier shortcuts typically used by assistive technology. - if (event.altKey) return; - - switch (event.keyCode) { - case KeyCode.SPACE: - case KeyCode.ENTER: - event.preventDefault(); - this._onClick(); - break; - // Any other key press is ignored and passed back to the browser. - default: - return; - } - }; - private readonly _onClick = () => { if (this.disabled) { return; diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index 95d118b4..2b844441 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -27,10 +27,10 @@ export class ChromecastButton extends StateReceiverMixin(CastButton, ['player']) // Make ID attributes unique const id = ++chromecastButtonId; - const mask = this.shadowRoot!.querySelector(`svg clipPath#${maskId}`)!; + const mask = this.shadowRoot!.querySelector(`svg clipPath#${maskId}`); const rings = this.shadowRoot!.querySelector(`svg .theoplayer-chromecast-rings`)!; const uniqueMaskId = `${maskId}-${id}`; - mask.setAttribute('id', uniqueMaskId); + mask?.setAttribute('id', uniqueMaskId); rings.setAttribute('clip-path', uniqueMaskId); this._upgradeProperty('player'); diff --git a/src/components/ErrorDisplay.ts b/src/components/ErrorDisplay.ts index 9967463e..d322d09b 100644 --- a/src/components/ErrorDisplay.ts +++ b/src/components/ErrorDisplay.ts @@ -3,7 +3,7 @@ import errorDisplayCss from './ErrorDisplay.css'; import errorIcon from '../icons/error.svg'; import { StateReceiverMixin } from './StateReceiverMixin'; import type { THEOplayerError } from 'theoplayer/chromeless'; -import { setTextContent } from '../util/CommonUtils'; +import { setTextContent, toggleAttribute } from '../util/CommonUtils'; import { Attribute } from '../util/Attribute'; const template = document.createElement('template'); @@ -72,11 +72,7 @@ export class ErrorDisplay extends StateReceiverMixin(HTMLElement, ['error', 'ful } set fullscreen(fullscreen: boolean) { - if (fullscreen) { - this.setAttribute(Attribute.FULLSCREEN, ''); - } else { - this.removeAttribute(Attribute.FULLSCREEN); - } + toggleAttribute(this, Attribute.FULLSCREEN, fullscreen); } attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { diff --git a/src/components/FullscreenButton.ts b/src/components/FullscreenButton.ts index a159d60c..db52f594 100644 --- a/src/components/FullscreenButton.ts +++ b/src/components/FullscreenButton.ts @@ -8,6 +8,7 @@ import { createCustomEvent } from '../util/EventUtils'; import { ENTER_FULLSCREEN_EVENT, type EnterFullscreenEvent } from '../events/EnterFullscreenEvent'; import { EXIT_FULLSCREEN_EVENT, type ExitFullscreenEvent } from '../events/ExitFullscreenEvent'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = buttonTemplate( @@ -42,11 +43,7 @@ export class FullscreenButton extends StateReceiverMixin(Button, ['fullscreen']) } set fullscreen(fullscreen: boolean) { - if (fullscreen) { - this.setAttribute(Attribute.FULLSCREEN, ''); - } else { - this.removeAttribute(Attribute.FULLSCREEN); - } + toggleAttribute(this, Attribute.FULLSCREEN, fullscreen); } protected override handleClick(): void { diff --git a/src/components/LanguageMenu.ts b/src/components/LanguageMenu.ts index 5edd466a..de091361 100644 --- a/src/components/LanguageMenu.ts +++ b/src/components/LanguageMenu.ts @@ -8,6 +8,7 @@ import { isSubtitleTrack } from '../util/TrackUtils'; import { Attribute } from '../util/Attribute'; import './TrackRadioGroup'; import './TextTrackStyleMenu'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = menuGroupTemplate(languageMenuHtml, languageMenuCss); @@ -56,21 +57,13 @@ export class LanguageMenu extends StateReceiverMixin(MenuGroup, ['player']) { private readonly _updateAudioTracks = (): void => { const newAudioTracks: readonly MediaTrack[] = this._player?.audioTracks ?? []; // Hide audio track selection if there's only one track. - if (newAudioTracks.length < 2) { - this.removeAttribute(Attribute.HAS_AUDIO); - } else { - this.setAttribute(Attribute.HAS_AUDIO, ''); - } + toggleAttribute(this, Attribute.HAS_AUDIO, newAudioTracks.length > 1); }; private readonly _updateTextTracks = (): void => { const newSubtitleTracks: readonly TextTrack[] = this._player?.textTracks.filter(isSubtitleTrack) ?? []; // Hide subtitle track selection if there are no tracks. If there's one, we still show an "off" option. - if (newSubtitleTracks.length === 0) { - this.removeAttribute(Attribute.HAS_SUBTITLES); - } else { - this.setAttribute(Attribute.HAS_SUBTITLES, ''); - } + toggleAttribute(this, Attribute.HAS_SUBTITLES, newSubtitleTracks.length > 0); }; override attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { diff --git a/src/components/LanguageMenuButton.ts b/src/components/LanguageMenuButton.ts index 41f4f765..a40944c5 100644 --- a/src/components/LanguageMenuButton.ts +++ b/src/components/LanguageMenuButton.ts @@ -6,6 +6,7 @@ import { StateReceiverMixin } from './StateReceiverMixin'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { isSubtitleTrack } from '../util/TrackUtils'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = buttonTemplate(`${languageIcon}`); @@ -59,11 +60,7 @@ export class LanguageMenuButton extends StateReceiverMixin(MenuButton, ['player' private readonly _updateTracks = (): void => { const hasTracks = this._player !== undefined && (this._player.audioTracks.length >= 2 || this._player.textTracks.some(isSubtitleTrack)); - if (hasTracks) { - this.removeAttribute('hidden'); - } else { - this.setAttribute('hidden', ''); - } + toggleAttribute(this, Attribute.HIDDEN, !hasTracks); }; } diff --git a/src/components/LinkButton.ts b/src/components/LinkButton.ts index d80845bc..587874f9 100644 --- a/src/components/LinkButton.ts +++ b/src/components/LinkButton.ts @@ -4,6 +4,7 @@ import { Attribute } from '../util/Attribute'; import type { ButtonOptions } from './Button'; import { Button, buttonTemplate } from './Button'; import { KeyCode } from '../util/KeyCode'; +import { toggleAttribute } from '../util/CommonUtils'; export function linkButtonTemplate(button: string, extraCss: string = ''): string { return buttonTemplate(`${button}`, `${linkButtonCss}\n${extraCss}`); @@ -79,11 +80,7 @@ export class LinkButton extends HTMLElement { } set disabled(disabled: boolean) { - if (disabled) { - this.setAttribute(Attribute.DISABLED, ''); - } else { - this.removeAttribute(Attribute.DISABLED); - } + toggleAttribute(this, Attribute.DISABLED, disabled); } protected setLink(href: string, target: string): void { diff --git a/src/components/LiveButton.ts b/src/components/LiveButton.ts index 01162fc7..62f0a942 100644 --- a/src/components/LiveButton.ts +++ b/src/components/LiveButton.ts @@ -6,6 +6,7 @@ import liveIcon from '../icons/live.svg'; import { StateReceiverMixin } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; import type { StreamType } from '../util/StreamType'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = buttonTemplate( @@ -59,11 +60,7 @@ export class LiveButton extends StateReceiverMixin(Button, ['player', 'streamTyp } set paused(paused: boolean) { - if (paused) { - this.setAttribute(Attribute.PAUSED, ''); - } else { - this.removeAttribute(Attribute.PAUSED); - } + toggleAttribute(this, Attribute.PAUSED, paused); } get streamType(): StreamType { @@ -88,11 +85,7 @@ export class LiveButton extends StateReceiverMixin(Button, ['player', 'streamTyp } set live(live: boolean) { - if (live) { - this.setAttribute(Attribute.LIVE, ''); - } else { - this.removeAttribute(Attribute.LIVE); - } + toggleAttribute(this, Attribute.LIVE, live); } get player(): ChromelessPlayer | undefined { @@ -121,7 +114,10 @@ export class LiveButton extends StateReceiverMixin(Button, ['player', 'streamTyp }; private readonly _updateLive = () => { - this.live = this._player !== undefined ? isLive(this._player, this.liveThreshold) : false; + const live = this._player !== undefined ? isLive(this._player, this.liveThreshold) : false; + if (this.live !== live) { + this.live = live; + } }; protected override handleClick() { diff --git a/src/components/LoadingIndicator.ts b/src/components/LoadingIndicator.ts index 10d67b94..5ba9470f 100644 --- a/src/components/LoadingIndicator.ts +++ b/src/components/LoadingIndicator.ts @@ -4,6 +4,7 @@ import loadingIndicatorHtml from './LoadingIndicator.html'; import { StateReceiverMixin } from './StateReceiverMixin'; import type { ChromelessPlayer } from 'theoplayer/chromeless'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = `${loadingIndicatorHtml}`; @@ -66,11 +67,7 @@ export class LoadingIndicator extends StateReceiverMixin(HTMLElement, ['player'] private readonly _updateFromPlayer = () => { const loading = this._player !== undefined && !this._player.paused && (this._player.seeking || this._player.readyState < 3); - if (loading) { - this.setAttribute(Attribute.LOADING, ''); - } else { - this.removeAttribute(Attribute.LOADING); - } + toggleAttribute(this, Attribute.LOADING, loading); }; attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { diff --git a/src/components/Menu.ts b/src/components/Menu.ts index 28ef96f2..94441c9b 100644 --- a/src/components/Menu.ts +++ b/src/components/Menu.ts @@ -4,6 +4,7 @@ import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent' import { MENU_CHANGE_EVENT, type MenuChangeEvent } from '../events/MenuChangeEvent'; import { createCustomEvent } from '../util/EventUtils'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; export interface MenuOptions { template?: HTMLTemplateElement; @@ -97,12 +98,7 @@ export class Menu extends HTMLElement { return; } if (attrName === Attribute.MENU_OPENED) { - const hasValue = newValue != null; - if (hasValue) { - this.removeAttribute('hidden'); - } else { - this.setAttribute('hidden', ''); - } + toggleAttribute(this, Attribute.HIDDEN, newValue == null); const changeEvent: MenuChangeEvent = createCustomEvent(MENU_CHANGE_EVENT, { bubbles: true }); this.dispatchEvent(changeEvent); } diff --git a/src/components/MenuGroup.ts b/src/components/MenuGroup.ts index 29b5ba13..6c85749f 100644 --- a/src/components/MenuGroup.ts +++ b/src/components/MenuGroup.ts @@ -4,7 +4,7 @@ import { Attribute } from '../util/Attribute'; import { arrayFind, arrayFindIndex, fromArrayLike, isElement, isHTMLElement, noOp } from '../util/CommonUtils'; import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent'; import { TOGGLE_MENU_EVENT, type ToggleMenuEvent } from '../events/ToggleMenuEvent'; -import { KeyCode } from '../util/KeyCode'; +import { isBackKey, KeyCode } from '../util/KeyCode'; import { createCustomEvent } from '../util/EventUtils'; import type { MenuChangeEvent } from '../events/MenuChangeEvent'; import { MENU_CHANGE_EVENT } from '../events/MenuChangeEvent'; @@ -326,16 +326,11 @@ export class MenuGroup extends HTMLElement { // Don't handle modifier shortcuts typically used by assistive technology. if (event.altKey) return; - switch (event.keyCode) { - case KeyCode.ESCAPE: - if (this.closeCurrentMenu()) { - event.preventDefault(); - event.stopPropagation(); - } - break; - // Any other key press is ignored and passed back to the browser. - default: - return; + if (isBackKey(event.keyCode)) { + if (this.closeCurrentMenu()) { + event.preventDefault(); + event.stopPropagation(); + } } }; } diff --git a/src/components/PlayButton.ts b/src/components/PlayButton.ts index ad1a53ea..289ffed8 100644 --- a/src/components/PlayButton.ts +++ b/src/components/PlayButton.ts @@ -7,6 +7,7 @@ import pauseIcon from '../icons/pause.svg'; import replayIcon from '../icons/replay.svg'; import { StateReceiverMixin } from './StateReceiverMixin'; import { Attribute } from '../util/Attribute'; +import { toggleAttribute } from '../util/CommonUtils'; const template = document.createElement('template'); template.innerHTML = buttonTemplate( @@ -51,11 +52,7 @@ export class PlayButton extends StateReceiverMixin(Button, ['player']) { } set paused(paused: boolean) { - if (paused) { - this.setAttribute(Attribute.PAUSED, ''); - } else { - this.removeAttribute(Attribute.PAUSED); - } + toggleAttribute(this, Attribute.PAUSED, paused); } get ended(): boolean { @@ -63,11 +60,7 @@ export class PlayButton extends StateReceiverMixin(Button, ['player']) { } set ended(ended: boolean) { - if (ended) { - this.setAttribute(Attribute.ENDED, ''); - } else { - this.removeAttribute(Attribute.ENDED); - } + toggleAttribute(this, Attribute.ENDED, ended); } get player(): ChromelessPlayer | undefined { diff --git a/src/components/RadioGroup.ts b/src/components/RadioGroup.ts index e3ac8627..b7d64fe8 100644 --- a/src/components/RadioGroup.ts +++ b/src/components/RadioGroup.ts @@ -1,9 +1,13 @@ import * as shadyCss from '@webcomponents/shadycss'; -import { KeyCode } from '../util/KeyCode'; +import { isArrowKey, KeyCode } from '../util/KeyCode'; import { RadioButton } from './RadioButton'; import { createEvent } from '../util/EventUtils'; import { arrayFind, isElement, noOp } from '../util/CommonUtils'; import './RadioButton'; +import { StateReceiverMixin } from './StateReceiverMixin'; +import { Attribute } from '../util/Attribute'; +import type { DeviceType } from '../util/DeviceType'; +import { navigateByArrowKey } from '../util/KeyboardNavigation'; const radioGroupTemplate = document.createElement('template'); radioGroupTemplate.innerHTML = ``; @@ -24,7 +28,7 @@ shadyCss.prepareTemplate(radioGroupTemplate, 'theoplayer-radio-group'); */ // Based on howto-radio-group // https://github.com/GoogleChromeLabs/howto-components/blob/079d0fa34ff9038b26ea8883b1db5dd6b677d7ba/elements/howto-radio-group/howto-radio-group.js -export class RadioGroup extends HTMLElement { +export class RadioGroup extends StateReceiverMixin(HTMLElement, ['deviceType']) { private _slot: HTMLSlotElement; private _radioButtons: RadioButton[] = []; @@ -34,6 +38,15 @@ export class RadioGroup extends HTMLElement { shadowRoot.appendChild(radioGroupTemplate.content.cloneNode(true)); this._slot = shadowRoot.querySelector('slot')!; + this._upgradeProperty('deviceType'); + } + + protected _upgradeProperty(prop: keyof this) { + if (this.hasOwnProperty(prop)) { + let value = this[prop]; + delete this[prop]; + this[prop] = value; + } } connectedCallback(): void { @@ -55,6 +68,14 @@ export class RadioGroup extends HTMLElement { this._slot.removeEventListener('slotchange', this._onSlotChange); } + get deviceType(): DeviceType { + return (this.getAttribute(Attribute.DEVICE_TYPE) || 'desktop') as DeviceType; + } + + set deviceType(deviceType: DeviceType) { + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); + } + private readonly _onSlotChange = () => { const children = this._slot.assignedNodes({ flatten: true }).filter(isElement); for (const child of children) { @@ -78,27 +99,36 @@ export class RadioGroup extends HTMLElement { } }; - private readonly _onKeyDown = (e: KeyboardEvent) => { - switch (e.keyCode) { + private readonly _onKeyDown = (event: KeyboardEvent) => { + if (this.deviceType === 'tv' && isArrowKey(event.keyCode)) { + if (navigateByArrowKey(this, this._radioButtons, event.keyCode)) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + switch (event.keyCode) { case KeyCode.UP: case KeyCode.LEFT: { - e.preventDefault(); - this._focusPrevButton(); + if (this._focusPrevButton()) { + event.preventDefault(); + } break; } case KeyCode.DOWN: case KeyCode.RIGHT: { - e.preventDefault(); - this._focusNextButton(); + if (this._focusNextButton()) { + event.preventDefault(); + } break; } case KeyCode.HOME: { - e.preventDefault(); + event.preventDefault(); this.setFocusedRadioButton(this.firstRadioButton); break; } case KeyCode.END: { - e.preventDefault(); + event.preventDefault(); this.setFocusedRadioButton(this.lastRadioButton); break; } @@ -145,28 +175,30 @@ export class RadioGroup extends HTMLElement { return null; } - private _focusPrevButton(): void { + private _focusPrevButton(): boolean { let focusedButton = this.focusedRadioButton || this.firstRadioButton; if (!focusedButton) { - return; + return false; } if (focusedButton === this.firstRadioButton) { this.setFocusedRadioButton(this.lastRadioButton); } else { this.setFocusedRadioButton(this._prevRadioButton(focusedButton)); } + return true; } - private _focusNextButton(): void { + private _focusNextButton(): boolean { let focusedButton = this.focusedRadioButton || this.firstRadioButton; if (!focusedButton) { - return; + return false; } if (focusedButton === this.lastRadioButton) { this.setFocusedRadioButton(this.firstRadioButton); } else { this.setFocusedRadioButton(this._nextRadioButton(focusedButton)); } + return true; } setFocusedRadioButton(button: RadioButton | null): void { diff --git a/src/components/Range.ts b/src/components/Range.ts index f304dbf8..3ed490f6 100644 --- a/src/components/Range.ts +++ b/src/components/Range.ts @@ -2,6 +2,10 @@ import * as shadyCss from '@webcomponents/shadycss'; import rangeCss from './Range.css'; import { Attribute } from '../util/Attribute'; import { ColorStops } from '../util/ColorStops'; +import { toggleAttribute } from '../util/CommonUtils'; +import { StateReceiverMixin } from './StateReceiverMixin'; +import type { DeviceType } from '../util/DeviceType'; +import { isArrowKey, KeyCode } from '../util/KeyCode'; export interface RangeOptions { template: HTMLTemplateElement; @@ -19,7 +23,7 @@ export function rangeTemplate(range: string, extraCss: string = ''): string { * * @group Components */ -export abstract class Range extends HTMLElement { +export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType']) { static get observedAttributes() { return [Attribute.DISABLED, Attribute.HIDDEN]; } @@ -37,6 +41,7 @@ export abstract class Range extends HTMLElement { this._rangeEl.addEventListener('input', this._onInput); // Internet Explorer does not fire 'input' events for elements... use 'change' instead. this._rangeEl.addEventListener('change', this._onInput); + this._rangeEl.addEventListener('keydown', this._onKeyDown); this._pointerEl = shadowRoot.querySelector('[part="pointer"]')!; @@ -45,6 +50,7 @@ export abstract class Range extends HTMLElement { this._upgradeProperty('min'); this._upgradeProperty('max'); this._upgradeProperty('step'); + this._upgradeProperty('deviceType'); } protected _upgradeProperty(prop: keyof this) { @@ -78,11 +84,7 @@ export abstract class Range extends HTMLElement { } set disabled(disabled: boolean) { - if (disabled) { - this.setAttribute(Attribute.DISABLED, ''); - } else { - this.removeAttribute(Attribute.DISABLED); - } + toggleAttribute(this, Attribute.DISABLED, disabled); } /** @@ -135,6 +137,14 @@ export abstract class Range extends HTMLElement { this._rangeEl.step = String(step); } + get deviceType(): DeviceType { + return (this.getAttribute(Attribute.DEVICE_TYPE) || 'desktop') as DeviceType; + } + + set deviceType(deviceType: DeviceType) { + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); + } + attributeChangedCallback(attrName: string, oldValue: any, newValue: any) { if (newValue === oldValue) { return; @@ -142,11 +152,7 @@ export abstract class Range extends HTMLElement { const hasValue = newValue != null; if (attrName === Attribute.DISABLED) { this.setAttribute('aria-disabled', hasValue ? 'true' : 'false'); - if (hasValue) { - this._rangeEl.setAttribute(attrName, newValue); - } else { - this._rangeEl.removeAttribute(attrName); - } + toggleAttribute(this._rangeEl, Attribute.DISABLED, hasValue); } else if (attrName === Attribute.HIDDEN) { if (!hasValue) { this.update(); @@ -244,4 +250,22 @@ export abstract class Range extends HTMLElement { protected updatePointer_(mousePercent: number, rangeRect: DOMRectReadOnly): void { this._pointerEl.style.width = `${mousePercent * rangeRect.width}px`; } + + private readonly _onKeyDown = (e: KeyboardEvent): void => { + this.handleKeyDown_(e); + }; + + protected handleKeyDown_(e: KeyboardEvent) { + if (this.deviceType === 'tv' && isArrowKey(e.keyCode)) { + // On TV devices, only allow left/right arrow keys to move the slider. + if (e.keyCode === KeyCode.LEFT || e.keyCode === KeyCode.RIGHT) { + // Stop propagation, to prevent from navigating to a different control + // while we're moving the slider. + e.stopPropagation(); + } else if (e.keyCode === KeyCode.UP || e.keyCode === KeyCode.DOWN) { + // Prevent default, to stop the browser from moving the slider. + e.preventDefault(); + } + } + } } diff --git a/src/components/StateReceiverMixin.ts b/src/components/StateReceiverMixin.ts index f4f1e36f..b070f8d6 100644 --- a/src/components/StateReceiverMixin.ts +++ b/src/components/StateReceiverMixin.ts @@ -1,5 +1,6 @@ -import { type Constructor, fromArrayLike, isArray, isElement, isHTMLElement, isHTMLSlotElement } from '../util/CommonUtils'; +import { type Constructor, fromArrayLike, getChildren, isArray } from '../util/CommonUtils'; import type { ChromelessPlayer, THEOplayerError, VideoQuality } from 'theoplayer/chromeless'; +import type { DeviceType } from '../util/DeviceType'; import type { StreamType } from '../util/StreamType'; /** @internal */ @@ -8,6 +9,7 @@ export const StateReceiverProps = 'theoplayerUiObservedProperties' as const; export interface StateReceiverPropertyMap { player: ChromelessPlayer | undefined; fullscreen: boolean; + deviceType: DeviceType; streamType: StreamType; playbackRate: number; activeVideoQuality: VideoQuality | undefined; @@ -86,14 +88,8 @@ export async function forEachStateReceiverElement( callback(element); } // Check all its children - const children: Element[] = [ - // Element.children does not exist for SVG elements in Internet Explorer. - // Assume those won't contain any state receivers. - ...(isHTMLElement(element) ? fromArrayLike(element.children) : []), - ...(element.shadowRoot ? fromArrayLike(element.shadowRoot.children) : []), - ...(isHTMLSlotElement(element) ? element.assignedNodes().filter(isElement) : []) - ]; + const children = getChildren(element); if (children.length > 0) { - await Promise.all(children.map((child) => forEachStateReceiverElement(child, playerElement, callback))); + await Promise.all(fromArrayLike(children).map((child) => forEachStateReceiverElement(child, playerElement, callback))); } } diff --git a/src/components/TimeRange.ts b/src/components/TimeRange.ts index 0a4182ce..b9bcac00 100644 --- a/src/components/TimeRange.ts +++ b/src/components/TimeRange.ts @@ -14,6 +14,7 @@ import './PreviewThumbnail'; import './PreviewTimeDisplay'; import { isLinearAd } from '../util/AdUtils'; import type { ColorStops } from '../util/ColorStops'; +import { KeyCode } from '../util/KeyCode'; const template = document.createElement('template'); template.innerHTML = rangeTemplate(timeRangeHtml, timeRangeCss); @@ -37,7 +38,7 @@ const AD_MARKER_WIDTH = 1; * the {@link PreviewThumbnail | preview thumbnail}. * @group Components */ -export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType']) { +export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType', 'deviceType']) { static get observedAttributes() { return [...Range.observedAttributes, Attribute.SHOW_AD_MARKERS]; } @@ -136,7 +137,9 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType' if (this._player !== undefined) { disabled ||= this._player.seekable.length === 0; } - this.disabled = disabled; + if (this.disabled !== disabled) { + this.disabled = disabled; + } }; protected override getAriaLabel(): string { @@ -306,6 +309,19 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType' private readonly _onAdChange = () => { this.update(); }; + + protected override handleKeyDown_(e: KeyboardEvent) { + super.handleKeyDown_(e); + if (this.deviceType === 'tv' && e.keyCode === KeyCode.ENTER) { + if (this._player !== undefined) { + if (this._player.paused) { + this._player.play(); + } else { + this._player.pause(); + } + } + } + } } customElements.define('theoplayer-time-range', TimeRange); diff --git a/src/components/TrackRadioGroup.ts b/src/components/TrackRadioGroup.ts index d788980b..06d839e7 100644 --- a/src/components/TrackRadioGroup.ts +++ b/src/components/TrackRadioGroup.ts @@ -8,7 +8,7 @@ import { MediaTrackRadioButton } from './MediaTrackRadioButton'; import { TextTrackRadioButton } from './TextTrackRadioButton'; import { isSubtitleTrack } from '../util/TrackUtils'; import { TextTrackOffRadioButton } from './TextTrackOffRadioButton'; -import { fromArrayLike } from '../util/CommonUtils'; +import { fromArrayLike, toggleAttribute } from '../util/CommonUtils'; import { createEvent } from '../util/EventUtils'; const template = document.createElement('template'); @@ -95,11 +95,7 @@ export class TrackRadioGroup extends StateReceiverMixin(HTMLElement, ['player']) } set showOffButton(value: boolean) { - if (value) { - this.setAttribute(Attribute.SHOW_OFF, ''); - } else { - this.removeAttribute(Attribute.SHOW_OFF); - } + toggleAttribute(this, Attribute.SHOW_OFF, value); } get player(): ChromelessPlayer | undefined { diff --git a/src/components/VolumeRange.ts b/src/components/VolumeRange.ts index fb2ac417..031b8845 100644 --- a/src/components/VolumeRange.ts +++ b/src/components/VolumeRange.ts @@ -16,7 +16,7 @@ function formatAsPercentString(value: number, max: number) { * * @group Components */ -export class VolumeRange extends StateReceiverMixin(Range, ['player']) { +export class VolumeRange extends StateReceiverMixin(Range, ['player', 'deviceType']) { private _player: ChromelessPlayer | undefined; constructor() { diff --git a/src/index.ts b/src/index.ts index f8c0aac6..cd5cec8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from './extensions/index'; export * from './UIContainer'; export * from './DefaultUI'; export { Attribute } from './util/Attribute'; +export { type DeviceType } from './util/DeviceType'; export { type StreamType } from './util/StreamType'; export { type Constructor } from './util/CommonUtils'; export { ColorStops } from './util/ColorStops'; diff --git a/src/util/Attribute.ts b/src/util/Attribute.ts index d5ac6654..ea0fb303 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -5,9 +5,14 @@ export enum Attribute { MUTED = 'muted', FULLSCREEN = 'fullscreen', FLUID = 'fluid', + DEVICE_TYPE = 'device-type', MOBILE = 'mobile', + TV = 'tv', + TV_FOCUS = 'tv-focus', MOBILE_ONLY = 'mobile-only', MOBILE_HIDDEN = 'mobile-hidden', + TV_ONLY = 'tv-only', + TV_HIDDEN = 'tv-hidden', USER_IDLE = 'user-idle', USER_IDLE_TIMEOUT = 'user-idle-timeout', NO_AUTO_HIDE = 'no-auto-hide', diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index ccd3b11c..10835926 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -1,3 +1,5 @@ +import { Attribute } from './Attribute'; + export type Constructor = abstract new (...args: any[]) => T; export function noOp(): void { @@ -69,6 +71,28 @@ export function arrayRemoveAt(array: T[], index: number): void { array.splice(index, 1); } +export type Comparator = (a: T, b: U) => number; + +export function arrayMinByKey(array: ReadonlyArray, keySelector: (element: T) => number): T | undefined { + return arrayMinBy(array, (first, second) => keySelector(first) - keySelector(second)); +} + +export function arrayMaxByKey(array: ReadonlyArray, keySelector: (element: T) => number): T | undefined { + return arrayMaxBy(array, (first, second) => keySelector(first) - keySelector(second)); +} + +export function arrayMinBy(array: ReadonlyArray, comparator: Comparator): T | undefined { + if (array.length === 0) { + return undefined; + } + return array.reduce((first, second) => (comparator(first, second) <= 0 ? first : second)); +} + +export function arrayMaxBy(array: ReadonlyArray, comparator: Comparator): T | undefined { + const minComparator = (a: T, b: T) => comparator(b, a); + return arrayMinBy(array, minComparator); +} + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith export const stringStartsWith: (string: string, search: string) => boolean = typeof String.prototype.startsWith === 'function' @@ -107,3 +131,103 @@ export function containsComposedNode(rootNode: Node, childNode: Node): boolean { } return false; } + +export function toggleAttribute(element: Element, attribute: string, enabled: boolean): void { + if (enabled) { + element.setAttribute(attribute, ''); + } else { + element.removeAttribute(attribute); + } +} + +export function getChildren(element: Element): ArrayLike { + if (element.shadowRoot) { + return element.shadowRoot.children; + } + if (isHTMLSlotElement(element)) { + const assignedNodes = element.assignedNodes(); + if (assignedNodes.length > 0) { + return assignedNodes.filter(isElement); + } + } + if (isHTMLElement(element)) { + // Element.children does not exist for SVG elements in Internet Explorer. + // Assume those won't contain any state receivers. + return element.children; + } + return []; +} + +export function getTvFocusChildren(element: Element): HTMLElement[] | undefined { + if (!isHTMLElement(element)) { + return; + } + if (getComputedStyle(element).display === 'none') { + return; + } + if (element.getAttribute(Attribute.TV_FOCUS) !== null) { + return getFocusableChildren(element); + } + + const children = getChildren(element); + for (let i = 0; i < children.length; i++) { + const result = getTvFocusChildren(children[i]); + if (result) { + return result; + } + } +} + +export function getFocusableChildren(element: HTMLElement): HTMLElement[] { + const result: HTMLElement[] = []; + collectFocusableChildren(element, result); + return result; +} + +function collectFocusableChildren(element: Element, result: HTMLElement[]) { + if (!isHTMLElement(element)) { + return; + } + if (getComputedStyle(element).display === 'none') { + return; + } + if (element.hasAttribute('tabindex') && Number(element.getAttribute('tabindex')) >= 0) { + result.push(element); + return; + } + switch (element.tagName.toLowerCase()) { + case 'button': + case 'input': + case 'textarea': + case 'select': + case 'details': { + result.push(element); + break; + } + case 'a': { + if ((element as HTMLAnchorElement).href) { + result.push(element); + } + break; + } + default: { + const children = getChildren(element); + for (let i = 0; i < children.length; i++) { + collectFocusableChildren(children[i], result); + } + break; + } + } +} + +export function getActiveElement(): Element | null { + let activeElement = document.activeElement; + if (activeElement == null) { + return null; + } + let nextActiveElement: Element | null | undefined; + while ((nextActiveElement = activeElement.shadowRoot?.activeElement) != null) { + activeElement = nextActiveElement; + } + return activeElement; +} diff --git a/src/util/DeviceType.ts b/src/util/DeviceType.ts new file mode 100644 index 00000000..2f7bbf7b --- /dev/null +++ b/src/util/DeviceType.ts @@ -0,0 +1 @@ +export type DeviceType = 'desktop' | 'mobile' | 'tv'; diff --git a/src/util/Environment.ts b/src/util/Environment.ts index 1e65c010..3c53df1d 100644 --- a/src/util/Environment.ts +++ b/src/util/Environment.ts @@ -23,3 +23,14 @@ export function isMobile(): boolean { } return /Android|iPhone|iPad|iPod|Mobile Safari|Windows Phone/i.test(userAgent); } + +export function isTv(): boolean { + if (typeof navigator !== 'object') { + return false; + } + const userAgent = navigator.userAgent; + if (!userAgent) { + return false; + } + return /\b(tv|smart-tv|smarttv|appletv|crkey|googletv|hbbtv|pov_tv|roku|viera|nettv|philipstv)\b/i.test(userAgent); +} diff --git a/src/util/GeometryUtils.ts b/src/util/GeometryUtils.ts index 8403bd4e..c258cb5d 100644 --- a/src/util/GeometryUtils.ts +++ b/src/util/GeometryUtils.ts @@ -64,6 +64,10 @@ export class Rectangle implements DOMRect { return new Rectangle(x, y, width, height); } + snapToPixels(): Rectangle { + return new Rectangle(Math.round(this.x), Math.round(this.y), Math.round(this.width), Math.round(this.height)); + } + clone(): Rectangle { return new Rectangle(this.x, this.y, this.width, this.height); } diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index fdc33064..11d76bb9 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -1,11 +1,27 @@ export enum KeyCode { - DOWN = 40, - LEFT = 37, - RIGHT = 39, - SPACE = 32, ENTER = 13, - UP = 38, - HOME = 36, + ESCAPE = 27, + SPACE = 32, END = 35, - ESCAPE = 27 + HOME = 36, + LEFT = 37, + UP = 38, + RIGHT = 39, + DOWN = 40, + // Multiple back key configurations depending on the platform + // https://suite.st/docs/faq/tv-specific-keys-in-browser/ + BACK_SAMSUNG = 88, + BACK_WEBOS = 461, + BACK_TIZEN = 10009 +} + +export type ArrowKeyCode = KeyCode.LEFT | KeyCode.UP | KeyCode.RIGHT | KeyCode.DOWN; +export type BackKeyCode = KeyCode.BACK_TIZEN | KeyCode.ESCAPE | KeyCode.BACK_SAMSUNG | KeyCode.BACK_WEBOS; + +export function isArrowKey(keyCode: number): keyCode is ArrowKeyCode { + return KeyCode.LEFT <= keyCode && keyCode <= KeyCode.DOWN; +} + +export function isBackKey(keyCode: number): keyCode is BackKeyCode { + return keyCode === KeyCode.BACK_TIZEN || keyCode === KeyCode.ESCAPE || keyCode === KeyCode.BACK_SAMSUNG || keyCode === KeyCode.BACK_WEBOS; } diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts new file mode 100644 index 00000000..319daf76 --- /dev/null +++ b/src/util/KeyboardNavigation.ts @@ -0,0 +1,89 @@ +import { type ArrowKeyCode, KeyCode } from './KeyCode'; +import { arrayMinByKey, getActiveElement, isHTMLElement } from './CommonUtils'; +import { Rectangle } from './GeometryUtils'; + +export function getFocusedChild(): HTMLElement | null { + const focusedChild = getActiveElement(); + if (!focusedChild || focusedChild === document.body || !isHTMLElement(focusedChild)) { + return null; + } + return focusedChild; +} + +export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { + const focusedChild = getFocusedChild(); + if (focusedChild === null) { + return false; + } + const containerRect = Rectangle.fromRect(container.getBoundingClientRect()).snapToPixels(); + const focusedRect = Rectangle.fromRect(focusedChild.getBoundingClientRect()).snapToPixels(); + const childrenWithRects = children + .map( + (child): ChildWithRect => ({ + child, + rect: Rectangle.fromRect(child.getBoundingClientRect()).snapToPixels() + }) + ) + .filter((x) => x.rect.width > 0 && x.rect.height > 0); + // Find focusable children next to the focused child along the key's direction + let candidates: ChildWithRect[]; + if (key === KeyCode.LEFT || key === KeyCode.RIGHT) { + const bounds = focusedRect.clone(); + if (key === KeyCode.LEFT) { + bounds.left = containerRect.left; + bounds.width = focusedRect.left - containerRect.left; + } else { + bounds.left = focusedRect.right; + bounds.width = containerRect.right - focusedRect.right; + } + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); + if (candidates.length === 0) { + bounds.top = containerRect.top; + bounds.height = containerRect.height; + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); + } + } else { + const bounds = focusedRect.clone(); + if (key === KeyCode.UP) { + bounds.top = containerRect.top; + bounds.height = focusedRect.top - containerRect.top; + } else { + bounds.top = focusedRect.bottom; + bounds.height = containerRect.bottom - focusedRect.bottom; + } + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); + if (candidates.length === 0) { + bounds.left = containerRect.left; + bounds.width = containerRect.width; + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); + } + } + // Find the candidate closest to the currently focused child + const closestCandidate = arrayMinByKey(candidates, (x) => manhattanDistanceBetweenRects(x.rect, focusedRect)); + if (closestCandidate === undefined) { + return false; + } + // Focus it + closestCandidate.child.focus(); + return true; +} + +interface ChildWithRect { + child: HTMLElement; + rect: DOMRectReadOnly; +} + +function manhattanDistanceBetweenRects(a: DOMRectReadOnly, b: DOMRectReadOnly): number { + let distance = 0; + if (a.right < b.left) { + distance += b.left - a.right; + } else if (b.right < a.left) { + distance += a.left - b.right; + } + if (a.bottom < b.top) { + distance += b.top - a.bottom; + } else if (b.bottom < a.top) { + distance += a.top - b.bottom; + } + return distance; +}