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;
+}