From 1fc1a062dfd1ac25d4867b4595af301b44524058 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 14:15:46 +0200 Subject: [PATCH 01/49] Add toggleAttribute helper --- src/DefaultUI.ts | 25 +++--------- src/UIContainer.ts | 57 ++++++---------------------- src/components/Button.ts | 7 +--- src/components/ErrorDisplay.ts | 8 +--- src/components/FullscreenButton.ts | 7 +--- src/components/LanguageMenu.ts | 13 ++----- src/components/LanguageMenuButton.ts | 7 +--- src/components/LinkButton.ts | 7 +--- src/components/LiveButton.ts | 13 ++----- src/components/LoadingIndicator.ts | 7 +--- src/components/Menu.ts | 8 +--- src/components/PlayButton.ts | 13 ++----- src/components/Range.ts | 13 ++----- src/components/TrackRadioGroup.ts | 8 +--- src/util/CommonUtils.ts | 8 ++++ 15 files changed, 52 insertions(+), 149 deletions(-) diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index a1eb5002..8ca6e43f 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -9,6 +9,7 @@ import { isMobile } from './util/Environment'; 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}`; @@ -258,17 +259,9 @@ 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); - } + toggleAttribute(this._ui, Attribute.FLUID, hasValue); } else if (attrName === Attribute.MOBILE) { - if (hasValue) { - this._ui.setAttribute(Attribute.MOBILE, newValue); - } else { - this._ui.removeAttribute(Attribute.MOBILE); - } + toggleAttribute(this._ui, Attribute.MOBILE, hasValue); } else if (attrName === Attribute.STREAM_TYPE) { this.streamType = newValue; } else if (attrName === Attribute.USER_IDLE_TIMEOUT) { @@ -284,19 +277,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.ts b/src/UIContainer.ts index 45f112b4..9a16ca5b 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -2,7 +2,7 @@ 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, 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'; @@ -249,11 +249,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 +260,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); } /** @@ -700,11 +692,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 +715,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 +736,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 +822,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 { diff --git a/src/components/Button.ts b/src/components/Button.ts index 41720b17..00d33754 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; @@ -87,11 +88,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) { 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..c5801c80 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 { 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/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/Range.ts b/src/components/Range.ts index f304dbf8..3809516d 100644 --- a/src/components/Range.ts +++ b/src/components/Range.ts @@ -2,6 +2,7 @@ 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'; export interface RangeOptions { template: HTMLTemplateElement; @@ -78,11 +79,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); } /** @@ -142,11 +139,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(); 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/util/CommonUtils.ts b/src/util/CommonUtils.ts index ccd3b11c..0d3e6d12 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -107,3 +107,11 @@ 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); + } +} From f006970a3f519f19d94216d6361202f1ce808fd4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 14:29:30 +0200 Subject: [PATCH 02/49] Add device-type attribute --- docs/examples/custom-ui.html | 6 +----- docs/examples/default-ui.html | 6 +----- src/DefaultUI.ts | 12 +++++++----- src/UIContainer.ts | 15 ++++++++++----- src/util/Attribute.ts | 1 + src/util/DeviceType.ts | 1 + 6 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 src/util/DeviceType.ts 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..76db64d3 100644 --- a/docs/examples/default-ui.html +++ b/docs/examples/default-ui.html @@ -24,10 +24,6 @@ diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index 8ca6e43f..d78880ac 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -41,8 +41,9 @@ 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" or "mobile". + * 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 `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 | ``}. @@ -66,7 +67,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, @@ -260,8 +261,9 @@ export class DefaultUI extends HTMLElement { this.autoplay = hasValue; } else if (attrName === Attribute.FLUID) { toggleAttribute(this._ui, Attribute.FLUID, hasValue); - } else if (attrName === Attribute.MOBILE) { - toggleAttribute(this._ui, Attribute.MOBILE, hasValue); + } else if (attrName === Attribute.DEVICE_TYPE) { + toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); + this._ui.setAttribute(Attribute.DEVICE_TYPE, newValue); } else if (attrName === Attribute.STREAM_TYPE) { this.streamType = newValue; } else if (attrName === Attribute.USER_IDLE_TIMEOUT) { diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 9a16ca5b..50513fba 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -22,6 +22,7 @@ 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'; const template = document.createElement('template'); template.innerHTML = `${elementHtml}`; @@ -57,8 +58,9 @@ 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" or "mobile". + * 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 `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 +104,7 @@ export class UIContainer extends HTMLElement { Attribute.AUTOPLAY, Attribute.FULLSCREEN, Attribute.FLUID, - Attribute.MOBILE, + Attribute.DEVICE_TYPE, Attribute.PAUSED, Attribute.ENDED, Attribute.CASTING, @@ -334,8 +336,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' : 'desktop'; + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); } if (!this.hasAttribute(Attribute.PAUSED)) { this.setAttribute(Attribute.PAUSED, ''); @@ -462,6 +465,8 @@ export class UIContainer extends HTMLElement { receiver.fullscreen = hasValue; } } + } else if (attrName === Attribute.DEVICE_TYPE) { + toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); } else if (attrName === Attribute.STREAM_TYPE) { for (const receiver of this._stateReceivers) { if (receiver[StateReceiverProps].indexOf('streamType') >= 0) { diff --git a/src/util/Attribute.ts b/src/util/Attribute.ts index d5ac6654..0566379e 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -5,6 +5,7 @@ export enum Attribute { MUTED = 'muted', FULLSCREEN = 'fullscreen', FLUID = 'fluid', + DEVICE_TYPE = 'device-type', MOBILE = 'mobile', MOBILE_ONLY = 'mobile-only', MOBILE_HIDDEN = 'mobile-hidden', diff --git a/src/util/DeviceType.ts b/src/util/DeviceType.ts new file mode 100644 index 00000000..c4946dc5 --- /dev/null +++ b/src/util/DeviceType.ts @@ -0,0 +1 @@ +export type DeviceType = 'desktop' | 'mobile'; From 23d36685bddb603475ec7854718ca379fd701156 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 14:30:29 +0200 Subject: [PATCH 03/49] Add 'tv' device type --- src/DefaultUI.ts | 4 +++- src/UIContainer.ts | 8 +++++--- src/util/Attribute.ts | 1 + src/util/DeviceType.ts | 2 +- src/util/Environment.ts | 11 +++++++++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index d78880ac..05247ce4 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -41,9 +41,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 `device-type` - The device type, either "desktop" or "mobile". + * @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 | ``}. @@ -263,6 +264,7 @@ export class DefaultUI extends HTMLElement { 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; diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 50513fba..2b28208e 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -10,7 +10,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'; @@ -58,9 +58,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 `device-type` - The device type, either "desktop" or "mobile". + * @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 | ``}. @@ -337,7 +338,7 @@ export class UIContainer extends HTMLElement { shadyCss.styleElement(this); if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { - const deviceType: DeviceType = isMobile() ? 'mobile' : 'desktop'; + const deviceType: DeviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; this.setAttribute(Attribute.DEVICE_TYPE, deviceType); } if (!this.hasAttribute(Attribute.PAUSED)) { @@ -467,6 +468,7 @@ export class UIContainer extends HTMLElement { } } else if (attrName === Attribute.DEVICE_TYPE) { toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); + toggleAttribute(this, Attribute.TV, newValue === 'tv'); } else if (attrName === Attribute.STREAM_TYPE) { for (const receiver of this._stateReceivers) { if (receiver[StateReceiverProps].indexOf('streamType') >= 0) { diff --git a/src/util/Attribute.ts b/src/util/Attribute.ts index 0566379e..9f7001a6 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -7,6 +7,7 @@ export enum Attribute { FLUID = 'fluid', DEVICE_TYPE = 'device-type', MOBILE = 'mobile', + TV = 'tv', MOBILE_ONLY = 'mobile-only', MOBILE_HIDDEN = 'mobile-hidden', USER_IDLE = 'user-idle', diff --git a/src/util/DeviceType.ts b/src/util/DeviceType.ts index c4946dc5..2f7bbf7b 100644 --- a/src/util/DeviceType.ts +++ b/src/util/DeviceType.ts @@ -1 +1 @@ -export type DeviceType = 'desktop' | 'mobile'; +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); +} From e5b2b04c51693d198ff2b5e1a417b8b2dd2f0d83 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 15:52:11 +0200 Subject: [PATCH 04/49] Add device type to state receivers --- src/UIContainer.ts | 15 +++++++++++++++ src/components/StateReceiverMixin.ts | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 2b28208e..617e6434 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -307,6 +307,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". * @@ -469,6 +476,11 @@ export class UIContainer extends HTMLElement { } else if (attrName === Attribute.DEVICE_TYPE) { toggleAttribute(this, Attribute.MOBILE, newValue === 'mobile'); toggleAttribute(this, Attribute.TV, newValue === 'tv'); + 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) { @@ -541,6 +553,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; } diff --git a/src/components/StateReceiverMixin.ts b/src/components/StateReceiverMixin.ts index f4f1e36f..e8661aa8 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 { 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; From f5fcfabf1934d7794f3b454027ed3d7fc0f0a91e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 15:52:36 +0200 Subject: [PATCH 05/49] Simplify --- src/components/StateReceiverMixin.ts | 12 +++--------- src/util/CommonUtils.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/StateReceiverMixin.ts b/src/components/StateReceiverMixin.ts index e8661aa8..b070f8d6 100644 --- a/src/components/StateReceiverMixin.ts +++ b/src/components/StateReceiverMixin.ts @@ -1,4 +1,4 @@ -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'; @@ -88,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/util/CommonUtils.ts b/src/util/CommonUtils.ts index 0d3e6d12..526039fb 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -115,3 +115,17 @@ export function toggleAttribute(element: Element, attribute: string, enabled: bo element.removeAttribute(attribute); } } + +export function getChildren(element: Element): ArrayLike { + if (element.shadowRoot) { + return element.shadowRoot.children; + } else if (isHTMLSlotElement(element)) { + return element.assignedNodes().filter(isElement); + } else 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; + } else { + return []; + } +} From 80cdfcaeb4fb54b8686f0cc0ea6d4eb8c29793ac Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 6 Apr 2023 16:38:43 +0200 Subject: [PATCH 06/49] Sort key codes --- src/util/KeyCode.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index fdc33064..f9cdc04a 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -1,11 +1,11 @@ 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 } From 12e00aca15887572a62c7dced22e544461506182 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 15:28:07 +0200 Subject: [PATCH 07/49] Add keyboard navigation for TVs --- src/UIContainer.ts | 8 +++- src/util/CommonUtils.ts | 69 +++++++++++++++++++++++++++ src/util/KeyCode.ts | 6 +++ src/util/KeyboardNavigation.ts | 87 ++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/util/KeyboardNavigation.ts diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 617e6434..166b75bc 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -23,6 +23,8 @@ import type { MenuGroup } from './components'; import './components/MenuGroup'; import { MENU_CHANGE_EVENT } from './events/MenuChangeEvent'; import type { DeviceType } from './util/DeviceType'; +import { isArrowKey } from './util/KeyCode'; +import { navigateByArrowKey } from './util/KeyboardNavigation'; const template = document.createElement('template'); template.innerHTML = `${elementHtml}`; @@ -897,9 +899,13 @@ export class UIContainer extends HTMLElement { return node === this || this._playerEl.contains(node); } - private readonly _onKeyUp = (): void => { + private readonly _onKeyUp = (event: KeyboardEvent): void => { // Show the controls while navigating with the keyboard. this.scheduleUserIdle_(); + if (this.deviceType === 'tv' && isArrowKey(event.keyCode) && navigateByArrowKey(this, event.keyCode)) { + event.preventDefault(); + event.stopPropagation(); + } }; private readonly _onPointerUp = (event: PointerEvent): void => { diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index 526039fb..80dcdbfd 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -69,6 +69,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' @@ -129,3 +151,50 @@ export function getChildren(element: Element): ArrayLike { return []; } } + +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 (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; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + return activeElement; +} diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index f9cdc04a..375b3734 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -9,3 +9,9 @@ export enum KeyCode { RIGHT = 39, DOWN = 40 } + +export type ArrowKeyCode = KeyCode.LEFT | KeyCode.UP | KeyCode.RIGHT | KeyCode.DOWN; + +export function isArrowKey(keyCode: number): keyCode is ArrowKeyCode { + return KeyCode.LEFT <= keyCode && keyCode <= KeyCode.DOWN; +} diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts new file mode 100644 index 00000000..6443e6e6 --- /dev/null +++ b/src/util/KeyboardNavigation.ts @@ -0,0 +1,87 @@ +import { type ArrowKeyCode, KeyCode } from './KeyCode'; +import { arrayMinByKey, getActiveElement, getFocusableChildren } from './CommonUtils'; +import { Rectangle } from './GeometryUtils'; + +export function navigateByArrowKey(container: HTMLElement, key: ArrowKeyCode): boolean { + const children = getFocusableChildren(container); + const focusedChild = getActiveElement(); + if (!focusedChild || children.indexOf(focusedChild as HTMLElement) < 0) { + // TODO Default focus? + if (children.length > 0) { + children[0].focus(); + return true; + } + return false; + } + const containerRect = Rectangle.fromRect(container.getBoundingClientRect()); + const focusedRect = Rectangle.fromRect(focusedChild.getBoundingClientRect()); + const childrenWithRects = children + .map( + (child): ChildWithRect => ({ + child, + rect: child.getBoundingClientRect() + }) + ) + .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.contains(x.rect)); + if (candidates.length === 0) { + bounds.top = containerRect.top; + bounds.height = containerRect.height; + candidates = childrenWithRects.filter((x) => bounds.contains(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.contains(x.rect)); + if (candidates.length === 0) { + bounds.left = containerRect.left; + bounds.width = containerRect.width; + candidates = childrenWithRects.filter((x) => bounds.contains(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; +} From 7241eaa8bc76e41c741020854d3f101f7262f599 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 16:04:50 +0200 Subject: [PATCH 08/49] Snap to pixels before comparing client rectangles --- src/util/GeometryUtils.ts | 4 ++++ src/util/KeyboardNavigation.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) 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/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index 6443e6e6..3c1514e1 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -13,13 +13,13 @@ export function navigateByArrowKey(container: HTMLElement, key: ArrowKeyCode): b } return false; } - const containerRect = Rectangle.fromRect(container.getBoundingClientRect()); - const focusedRect = Rectangle.fromRect(focusedChild.getBoundingClientRect()); + const containerRect = Rectangle.fromRect(container.getBoundingClientRect()).snapToPixels(); + const focusedRect = Rectangle.fromRect(focusedChild.getBoundingClientRect()).snapToPixels(); const childrenWithRects = children .map( (child): ChildWithRect => ({ child, - rect: child.getBoundingClientRect() + rect: Rectangle.fromRect(child.getBoundingClientRect()).snapToPixels() }) ) .filter((x) => x.rect.width > 0 && x.rect.height > 0); From beff653371097085a2aec8c6257cd567e68ccfc8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 16:05:46 +0200 Subject: [PATCH 09/49] Handle keyboard navigation in radio groups --- src/UIContainer.ts | 24 +++++++++++--- src/components/RadioGroup.ts | 60 ++++++++++++++++++++++++++-------- src/util/KeyboardNavigation.ts | 5 ++- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 166b75bc..f81ace7b 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -2,7 +2,16 @@ 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, toggleAttribute } from './util/CommonUtils'; +import { + arrayFind, + arrayRemove, + containsComposedNode, + getFocusableChildren, + 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'; @@ -376,6 +385,7 @@ export class UIContainer extends HTMLElement { } this.setUserIdle_(); + this.addEventListener('keydown', this._onKeyDown); this.addEventListener('keyup', this._onKeyUp); this.addEventListener('pointerup', this._onPointerUp); this.addEventListener('pointermove', this._onPointerMove); @@ -440,6 +450,7 @@ export class UIContainer extends HTMLElement { document.removeEventListener(fullscreenAPI.fullscreenerror_, this._onFullscreenChange); } + this.removeEventListener('keydown', this._onKeyDown); this.removeEventListener('keyup', this._onKeyUp); this.removeEventListener('pointerup', this._onPointerUp); this.removeEventListener('click', this._onClickAfterPointerUp, true); @@ -899,15 +910,18 @@ export class UIContainer extends HTMLElement { return node === this || this._playerEl.contains(node); } - private readonly _onKeyUp = (event: KeyboardEvent): void => { - // Show the controls while navigating with the keyboard. - this.scheduleUserIdle_(); - if (this.deviceType === 'tv' && isArrowKey(event.keyCode) && navigateByArrowKey(this, event.keyCode)) { + private readonly _onKeyDown = (event: KeyboardEvent): void => { + if (this.deviceType === 'tv' && isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { event.preventDefault(); event.stopPropagation(); } }; + private readonly _onKeyUp = (): void => { + // Show the controls while navigating with the keyboard. + this.scheduleUserIdle_(); + }; + private readonly _onPointerUp = (event: PointerEvent): void => { if (event.pointerType === 'touch') { // On mobile, when you tap the media while the controls are showing, immediately hide the controls. 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/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index 3c1514e1..fe2e2185 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -1,9 +1,8 @@ import { type ArrowKeyCode, KeyCode } from './KeyCode'; -import { arrayMinByKey, getActiveElement, getFocusableChildren } from './CommonUtils'; +import { arrayMinByKey, getActiveElement } from './CommonUtils'; import { Rectangle } from './GeometryUtils'; -export function navigateByArrowKey(container: HTMLElement, key: ArrowKeyCode): boolean { - const children = getFocusableChildren(container); +export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { const focusedChild = getActiveElement(); if (!focusedChild || children.indexOf(focusedChild as HTMLElement) < 0) { // TODO Default focus? From 04dbde4ad92bc19dc84c1f85420c9e35c494b71a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 16:05:58 +0200 Subject: [PATCH 10/49] Tiny fix --- src/util/KeyboardNavigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index fe2e2185..ac55a23c 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -4,7 +4,7 @@ import { Rectangle } from './GeometryUtils'; export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { const focusedChild = getActiveElement(); - if (!focusedChild || children.indexOf(focusedChild as HTMLElement) < 0) { + if (!focusedChild) { // TODO Default focus? if (children.length > 0) { children[0].focus(); From d8f88c68fca068b3fa6c742cede81dd0f2f3549a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 16:51:03 +0200 Subject: [PATCH 11/49] Allow partial overlap when looking for a keyboard navigation target --- src/util/KeyboardNavigation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index ac55a23c..a1808461 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -33,11 +33,11 @@ export function navigateByArrowKey(container: HTMLElement, children: HTMLElement bounds.left = focusedRect.right; bounds.width = containerRect.right - focusedRect.right; } - candidates = childrenWithRects.filter((x) => bounds.contains(x.rect)); + 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.contains(x.rect)); + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); } } else { const bounds = focusedRect.clone(); @@ -48,11 +48,11 @@ export function navigateByArrowKey(container: HTMLElement, children: HTMLElement bounds.top = focusedRect.bottom; bounds.height = containerRect.bottom - focusedRect.bottom; } - candidates = childrenWithRects.filter((x) => bounds.contains(x.rect)); + 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.contains(x.rect)); + candidates = childrenWithRects.filter((x) => bounds.overlaps(x.rect)); } } // Find the candidate closest to the currently focused child From f4353068c574c45d36b42a6b892d1bd2f3b8e4c0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 16:51:41 +0200 Subject: [PATCH 12/49] Only allow left/right arrow keys to move the Range slider on TV devices --- src/components/Range.ts | 29 ++++++++++++++++++++++++++++- src/components/TimeRange.ts | 2 +- src/components/VolumeRange.ts | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/Range.ts b/src/components/Range.ts index 3809516d..bdffd791 100644 --- a/src/components/Range.ts +++ b/src/components/Range.ts @@ -3,6 +3,9 @@ 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; @@ -20,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]; } @@ -38,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"]')!; @@ -46,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) { @@ -132,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; @@ -237,4 +250,18 @@ 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 => { + 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/TimeRange.ts b/src/components/TimeRange.ts index 0a4182ce..81cbcac8 100644 --- a/src/components/TimeRange.ts +++ b/src/components/TimeRange.ts @@ -37,7 +37,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]; } 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() { From 8dc562b3bd05e6ab13b0f8efc738f276bb7b8b98 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 7 Apr 2023 17:02:39 +0200 Subject: [PATCH 13/49] Fix preview time display no longer updating --- src/util/CommonUtils.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index 80dcdbfd..138fd5a7 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -141,15 +141,19 @@ export function toggleAttribute(element: Element, attribute: string, enabled: bo export function getChildren(element: Element): ArrayLike { if (element.shadowRoot) { return element.shadowRoot.children; - } else if (isHTMLSlotElement(element)) { - return element.assignedNodes().filter(isElement); - } else if (isHTMLElement(element)) { + } + 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; - } else { - return []; } + return []; } export function getFocusableChildren(element: HTMLElement): HTMLElement[] { From c9e5f1fc86d0014630ef3d0af9e19c28c83fb9c9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 23 May 2023 17:26:37 +0200 Subject: [PATCH 14/49] Add dropdown to override device type in example --- docs/examples/default-ui.html | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/examples/default-ui.html b/docs/examples/default-ui.html index 76db64d3..b94b5ee6 100644 --- a/docs/examples/default-ui.html +++ b/docs/examples/default-ui.html @@ -19,11 +19,22 @@ Elephant's Dream

- +

From 539c5371265051af043a867d43664f7a12c6f68a Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 13 Sep 2023 11:43:38 +0200 Subject: [PATCH 15/49] Hide unused elements on tv --- src/DefaultUI.css | 8 ++++++++ src/DefaultUI.html | 8 ++++---- src/DefaultUI.ts | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/DefaultUI.css b/src/DefaultUI.css index 6801c85b..73a0d155 100644 --- a/src/DefaultUI.css +++ b/src/DefaultUI.css @@ -160,6 +160,14 @@ theoplayer-ad-skip-button:not([disabled]) { display: none !important; } +/* + * Tv-hidden elements + */ +theoplayer-ui[tv] [tv-hidden], +theoplayer-ui[tv] theoplayer-control-bar ::slotted([tv-hidden]) { + display: none !important; +} + /* * Live-only and live-hidden elements */ diff --git a/src/DefaultUI.html b/src/DefaultUI.html index a8a68983..cf2adb6e 100644 --- a/src/DefaultUI.html +++ b/src/DefaultUI.html @@ -33,14 +33,14 @@ - - + + - - + + diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index 05247ce4..d8339216 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -5,7 +5,7 @@ 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 { StreamType } from './util/StreamType'; import type { TimeRange } from './components/TimeRange'; import { STREAM_TYPE_CHANGE_EVENT } from './events/StreamTypeChangeEvent'; @@ -232,6 +232,8 @@ export class DefaultUI extends HTMLElement { if (!this.hasAttribute(Attribute.MOBILE) && isMobile()) { this.setAttribute(Attribute.MOBILE, ''); + } else if (!this.hasAttribute(Attribute.TV) && isTv()) { + this.setAttribute(Attribute.TV, ''); } if (!this._appliedExtensions) { From d84732e236cad5fa7142a40bdacf10a7ea5de1f4 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 14 Sep 2023 09:50:34 +0200 Subject: [PATCH 16/49] TV play pause using seekbar --- src/components/Range.ts | 6 +++++- src/components/TimeRange.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/Range.ts b/src/components/Range.ts index bdffd791..3ed490f6 100644 --- a/src/components/Range.ts +++ b/src/components/Range.ts @@ -252,6 +252,10 @@ export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType } 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) { @@ -263,5 +267,5 @@ export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType e.preventDefault(); } } - }; + } } diff --git a/src/components/TimeRange.ts b/src/components/TimeRange.ts index 81cbcac8..2bb3a732 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); @@ -306,6 +307,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); From 21f738df0d5fe5c18b06c3c18267771b8b36e6c4 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 21 Sep 2023 12:25:03 +0200 Subject: [PATCH 17/49] Stop event propagation when button is pressed --- src/components/Button.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Button.ts b/src/components/Button.ts index 00d33754..8c221907 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -119,6 +119,7 @@ export class Button extends HTMLElement { case KeyCode.SPACE: case KeyCode.ENTER: event.preventDefault(); + event.stopPropagation(); this._onClick(); break; // Any other key press is ignored and passed back to the browser. From 4880623e4900ab7180e2972732d7d79b4f983d00 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 21 Sep 2023 13:46:14 +0200 Subject: [PATCH 18/49] Listen to keydown event on the window instead --- src/UIContainer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index f81ace7b..3942b293 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -385,7 +385,7 @@ export class UIContainer extends HTMLElement { } this.setUserIdle_(); - this.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keydown', this._onKeyDown); this.addEventListener('keyup', this._onKeyUp); this.addEventListener('pointerup', this._onPointerUp); this.addEventListener('pointermove', this._onPointerMove); @@ -450,7 +450,7 @@ export class UIContainer extends HTMLElement { document.removeEventListener(fullscreenAPI.fullscreenerror_, this._onFullscreenChange); } - this.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keydown', this._onKeyDown); this.removeEventListener('keyup', this._onKeyUp); this.removeEventListener('pointerup', this._onPointerUp); this.removeEventListener('click', this._onClickAfterPointerUp, true); From d5452c02280b7e5a4fc9d7a20cea9961c7f9e361 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 3 Oct 2023 10:54:44 +0200 Subject: [PATCH 19/49] Only update if value is different --- src/components/LiveButton.ts | 5 ++++- src/components/TimeRange.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/LiveButton.ts b/src/components/LiveButton.ts index c5801c80..62f0a942 100644 --- a/src/components/LiveButton.ts +++ b/src/components/LiveButton.ts @@ -114,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/TimeRange.ts b/src/components/TimeRange.ts index 2bb3a732..b9bcac00 100644 --- a/src/components/TimeRange.ts +++ b/src/components/TimeRange.ts @@ -137,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 { From 36788f1edacca5be3ca1840bd8ddc43413bea751 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 3 Oct 2023 11:56:38 +0200 Subject: [PATCH 20/49] Handle TV back button --- src/UIContainer.ts | 18 ++++++++++++------ src/util/KeyCode.ts | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 3942b293..5e2eeafa 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -32,7 +32,7 @@ import type { MenuGroup } from './components'; import './components/MenuGroup'; import { MENU_CHANGE_EVENT } from './events/MenuChangeEvent'; import type { DeviceType } from './util/DeviceType'; -import { isArrowKey } from './util/KeyCode'; +import { isArrowKey, KeyCode } from './util/KeyCode'; import { navigateByArrowKey } from './util/KeyboardNavigation'; const template = document.createElement('template'); @@ -911,15 +911,21 @@ export class UIContainer extends HTMLElement { } private readonly _onKeyDown = (event: KeyboardEvent): void => { - if (this.deviceType === 'tv' && isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { - event.preventDefault(); - event.stopPropagation(); + if (this.deviceType === 'tv') { + if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { + event.preventDefault(); + event.stopPropagation(); + } else if (event.keyCode === KeyCode.BACK) { + this.setUserIdle_(); + } } }; - private readonly _onKeyUp = (): void => { + private readonly _onKeyUp = (event: KeyboardEvent): void => { // Show the controls while navigating with the keyboard. - this.scheduleUserIdle_(); + if (event.keyCode !== KeyCode.BACK) { + this.scheduleUserIdle_(); + } }; private readonly _onPointerUp = (event: PointerEvent): void => { diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index 375b3734..f5c88abb 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -7,7 +7,8 @@ export enum KeyCode { LEFT = 37, UP = 38, RIGHT = 39, - DOWN = 40 + DOWN = 40, + BACK = 10009 } export type ArrowKeyCode = KeyCode.LEFT | KeyCode.UP | KeyCode.RIGHT | KeyCode.DOWN; From b0627d17e8fa7486d3572df11caf88a945a370b0 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 3 Oct 2023 11:57:44 +0200 Subject: [PATCH 21/49] Increase default tv idle timeout --- src/UIContainer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 5e2eeafa..2d527524 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -40,6 +40,7 @@ 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; /** @@ -310,7 +311,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) { From 6a4918e4af7980cdc6f1ca5eef3d36e0505b64f6 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 3 Oct 2023 16:13:39 +0200 Subject: [PATCH 22/49] Only collect visible focusable children --- src/util/CommonUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index 138fd5a7..c202e2f0 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -166,6 +166,9 @@ 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; From 3812038297d1097afbcdb81fead6029fa26d62df Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 3 Oct 2023 16:16:26 +0200 Subject: [PATCH 23/49] Improve getting active element during navigation --- src/util/KeyboardNavigation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index a1808461..8a981714 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -4,8 +4,7 @@ import { Rectangle } from './GeometryUtils'; export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { const focusedChild = getActiveElement(); - if (!focusedChild) { - // TODO Default focus? + if (!focusedChild || focusedChild === document.body) { if (children.length > 0) { children[0].focus(); return true; From ca0790e8b8fa11e789c37e42f70bab205b381408 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 4 Oct 2023 17:15:02 +0200 Subject: [PATCH 24/49] Update menus TV layout --- src/UIContainer.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/UIContainer.css b/src/UIContainer.css index 5f5a912d..1f776ca7 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; } From 47ddb6482005e5126a063e7c5a92d0a0396e0596 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 5 Oct 2023 13:58:20 +0200 Subject: [PATCH 25/49] Extract getFocusedChild --- src/util/KeyboardNavigation.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index 8a981714..fc3b6ce0 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -2,13 +2,21 @@ import { type ArrowKeyCode, KeyCode } from './KeyCode'; import { arrayMinByKey, getActiveElement } from './CommonUtils'; import { Rectangle } from './GeometryUtils'; -export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { +export function getFocusedChild(children: HTMLElement[]): Element | null { const focusedChild = getActiveElement(); if (!focusedChild || focusedChild === document.body) { if (children.length > 0) { children[0].focus(); - return true; + return children[0]; } + return null; + } + return focusedChild; +} + +export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { + const focusedChild = getFocusedChild(children); + if (focusedChild === null) { return false; } const containerRect = Rectangle.fromRect(container.getBoundingClientRect()).snapToPixels(); From e79b22a2394b1790032c8e279f170d989f4d10e5 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 10 Oct 2023 10:32:05 +0200 Subject: [PATCH 26/49] Handle key inputs only when UI is visible --- src/UIContainer.ts | 17 +++++++++++++++-- src/components/Button.ts | 19 ------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 2d527524..40e9d1c1 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -6,6 +6,7 @@ import { arrayFind, arrayRemove, containsComposedNode, + getActiveElement, getFocusableChildren, isElement, isHTMLElement, @@ -33,7 +34,7 @@ import './components/MenuGroup'; import { MENU_CHANGE_EVENT } from './events/MenuChangeEvent'; import type { DeviceType } from './util/DeviceType'; import { isArrowKey, KeyCode } from './util/KeyCode'; -import { navigateByArrowKey } from './util/KeyboardNavigation'; +import { getFocusedChild, navigateByArrowKey } from './util/KeyboardNavigation'; const template = document.createElement('template'); template.innerHTML = `${elementHtml}`; @@ -914,7 +915,19 @@ export class UIContainer extends HTMLElement { private readonly _onKeyDown = (event: KeyboardEvent): void => { if (this.deviceType === 'tv') { - if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { + if (this.isUserIdle_()) { + // First button press should only make the UI visible + getFocusedChild(getFocusableChildren(this)); + return; + } + if (event.keyCode === KeyCode.ENTER) { + if (this._player !== undefined) { + const focusedChild = getActiveElement(); + if (isHTMLElement(focusedChild)) { + focusedChild.click(); + } + } + } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { event.preventDefault(); event.stopPropagation(); } else if (event.keyCode === KeyCode.BACK) { diff --git a/src/components/Button.ts b/src/components/Button.ts index 8c221907..af53a55c 100644 --- a/src/components/Button.ts +++ b/src/components/Button.ts @@ -69,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); } @@ -111,23 +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(); - event.stopPropagation(); - this._onClick(); - break; - // Any other key press is ignored and passed back to the browser. - default: - return; - } - }; - private readonly _onClick = () => { if (this.disabled) { return; From 5d3b79b647b56d207f770c1a463a69d0298981a0 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 5 Oct 2023 15:35:58 +0200 Subject: [PATCH 27/49] Check multiple BACK key codes --- src/UIContainer.ts | 6 +++--- src/util/KeyCode.ts | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 40e9d1c1..1235982d 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -33,8 +33,8 @@ import type { MenuGroup } from './components'; import './components/MenuGroup'; import { MENU_CHANGE_EVENT } from './events/MenuChangeEvent'; import type { DeviceType } from './util/DeviceType'; -import { isArrowKey, KeyCode } from './util/KeyCode'; import { getFocusedChild, navigateByArrowKey } from './util/KeyboardNavigation'; +import { isArrowKey, isBackKey, KeyCode } from './util/KeyCode'; const template = document.createElement('template'); template.innerHTML = `${elementHtml}`; @@ -930,7 +930,7 @@ export class UIContainer extends HTMLElement { } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { event.preventDefault(); event.stopPropagation(); - } else if (event.keyCode === KeyCode.BACK) { + } else if (isBackKey(event.keyCode)) { this.setUserIdle_(); } } @@ -938,7 +938,7 @@ export class UIContainer extends HTMLElement { private readonly _onKeyUp = (event: KeyboardEvent): void => { // Show the controls while navigating with the keyboard. - if (event.keyCode !== KeyCode.BACK) { + if (!isBackKey(event.keyCode)) { this.scheduleUserIdle_(); } }; diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index f5c88abb..c7558cc2 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -8,11 +8,16 @@ export enum KeyCode { UP = 38, RIGHT = 39, DOWN = 40, - BACK = 10009 + BACK_TIZEN = 10009 } export type ArrowKeyCode = KeyCode.LEFT | KeyCode.UP | KeyCode.RIGHT | KeyCode.DOWN; +export type BackKeyCode = KeyCode.BACK_TIZEN | KeyCode.ESCAPE; 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; +} From 0d72630bea5a3a8ad30ec927a715a78b0e53e2fd Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 5 Oct 2023 15:36:26 +0200 Subject: [PATCH 28/49] Close menu on back key press --- src/components/MenuGroup.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) 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(); + } } }; } From a5bfcce611ec08d9096c2614dc3faaee3e236c7c Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Tue, 10 Oct 2023 15:07:16 +0200 Subject: [PATCH 29/49] Play from first OK press on tv --- src/UIContainer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 1235982d..6db5d880 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -915,14 +915,13 @@ export class UIContainer extends HTMLElement { private readonly _onKeyDown = (event: KeyboardEvent): void => { if (this.deviceType === 'tv') { + const focusedChild = getFocusedChild(getFocusableChildren(this)); if (this.isUserIdle_()) { // First button press should only make the UI visible - getFocusedChild(getFocusableChildren(this)); return; } if (event.keyCode === KeyCode.ENTER) { if (this._player !== undefined) { - const focusedChild = getActiveElement(); if (isHTMLElement(focusedChild)) { focusedChild.click(); } From d4fd5dfe289d12ae000fada8f5ce387bb76b0997 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 11 Oct 2023 14:48:20 +0200 Subject: [PATCH 30/49] Add more back key codes --- src/util/KeyCode.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/util/KeyCode.ts b/src/util/KeyCode.ts index c7558cc2..11d76bb9 100644 --- a/src/util/KeyCode.ts +++ b/src/util/KeyCode.ts @@ -8,16 +8,20 @@ export enum KeyCode { 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; +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; + return keyCode === KeyCode.BACK_TIZEN || keyCode === KeyCode.ESCAPE || keyCode === KeyCode.BACK_SAMSUNG || keyCode === KeyCode.BACK_WEBOS; } From cc1c24f5b2e294b25f5c6e898f9f75c58da02621 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 11 Oct 2023 16:41:58 +0200 Subject: [PATCH 31/49] Return HTML element --- src/UIContainer.ts | 6 ++---- src/util/KeyboardNavigation.ts | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 6db5d880..57a19138 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -921,10 +921,8 @@ export class UIContainer extends HTMLElement { return; } if (event.keyCode === KeyCode.ENTER) { - if (this._player !== undefined) { - if (isHTMLElement(focusedChild)) { - focusedChild.click(); - } + if (this._player !== undefined && focusedChild !== null) { + focusedChild.click(); } } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { event.preventDefault(); diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index fc3b6ce0..e4b47d2a 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -1,10 +1,10 @@ import { type ArrowKeyCode, KeyCode } from './KeyCode'; -import { arrayMinByKey, getActiveElement } from './CommonUtils'; +import { arrayMinByKey, getActiveElement, isHTMLElement } from './CommonUtils'; import { Rectangle } from './GeometryUtils'; -export function getFocusedChild(children: HTMLElement[]): Element | null { +export function getFocusedChild(children: HTMLElement[]): HTMLElement | null { const focusedChild = getActiveElement(); - if (!focusedChild || focusedChild === document.body) { + if (!focusedChild || focusedChild === document.body || !isHTMLElement(focusedChild)) { if (children.length > 0) { children[0].focus(); return children[0]; From dfad37026b38b6b6cf7ffdc2da2a839cdb35262f Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 11 Oct 2023 16:44:05 +0200 Subject: [PATCH 32/49] Blur focused child when idle --- src/UIContainer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 57a19138..044b430e 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -891,8 +891,13 @@ export class UIContainer extends HTMLElement { if (this.userIdleTimeout < 0) { return; } - this.setAttribute(Attribute.USER_IDLE, ''); + + // Blur active element so that first key press on TV doesn't result in an action. + const focusedChild = getFocusedChild(getFocusableChildren(this)); + if (this.deviceType === 'tv' && focusedChild !== null && this.isUserIdle_()) { + focusedChild.blur(); + } }; private readonly scheduleUserIdle_ = (): void => { From a6167e679987de22694dc126a3f5048c9e4067a4 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Wed, 11 Oct 2023 16:57:41 +0200 Subject: [PATCH 33/49] Do not focus when pressing back key --- src/UIContainer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 044b430e..c8906d46 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -920,6 +920,11 @@ export class UIContainer extends HTMLElement { private readonly _onKeyDown = (event: KeyboardEvent): void => { if (this.deviceType === 'tv') { + if (isBackKey(event.keyCode)) { + this.setUserIdle_(); + return; + } + const focusedChild = getFocusedChild(getFocusableChildren(this)); if (this.isUserIdle_()) { // First button press should only make the UI visible @@ -932,8 +937,6 @@ export class UIContainer extends HTMLElement { } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { event.preventDefault(); event.stopPropagation(); - } else if (isBackKey(event.keyCode)) { - this.setUserIdle_(); } } }; From 252b96ffb306a28b9faf899125aa650501fa701f Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 12 Oct 2023 17:15:27 +0200 Subject: [PATCH 34/49] Fix possible TypeError --- src/components/ChromecastButton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index 95d118b4..ace83cdb 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -30,7 +30,7 @@ export class ChromecastButton extends StateReceiverMixin(CastButton, ['player']) 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'); From c8ac8ceaa598f8326e9d4138d163cb9409dca88b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:40:38 +0200 Subject: [PATCH 35/49] Optimize getActiveElement slightly --- src/util/CommonUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index c202e2f0..2c3932bd 100644 --- a/src/util/CommonUtils.ts +++ b/src/util/CommonUtils.ts @@ -200,8 +200,12 @@ function collectFocusableChildren(element: Element, result: HTMLElement[]) { export function getActiveElement(): Element | null { let activeElement = document.activeElement; - while (activeElement?.shadowRoot?.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; + if (activeElement == null) { + return null; + } + let nextActiveElement: Element | null | undefined; + while ((nextActiveElement = activeElement.shadowRoot?.activeElement) != null) { + activeElement = nextActiveElement; } return activeElement; } From 5a242fca07849732a92714fb89a692043b2c3339 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:45:01 +0200 Subject: [PATCH 36/49] Add keydown listener only on TVs --- src/UIContainer.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index c8906d46..3e9bcad1 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -388,7 +388,9 @@ export class UIContainer extends HTMLElement { } this.setUserIdle_(); - window.addEventListener('keydown', this._onKeyDown); + if (this.deviceType === 'tv') { + window.addEventListener('keydown', this._onTvKeyDown); + } this.addEventListener('keyup', this._onKeyUp); this.addEventListener('pointerup', this._onPointerUp); this.addEventListener('pointermove', this._onPointerMove); @@ -453,7 +455,7 @@ export class UIContainer extends HTMLElement { document.removeEventListener(fullscreenAPI.fullscreenerror_, this._onFullscreenChange); } - window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keydown', this._onTvKeyDown); this.removeEventListener('keyup', this._onKeyUp); this.removeEventListener('pointerup', this._onPointerUp); this.removeEventListener('click', this._onClickAfterPointerUp, true); @@ -492,6 +494,10 @@ export class UIContainer extends HTMLElement { } 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; @@ -918,7 +924,7 @@ export class UIContainer extends HTMLElement { return node === this || this._playerEl.contains(node); } - private readonly _onKeyDown = (event: KeyboardEvent): void => { + private readonly _onTvKeyDown = (event: KeyboardEvent): void => { if (this.deviceType === 'tv') { if (isBackKey(event.keyCode)) { this.setUserIdle_(); From 7df63e7f18bb954145927e49b222bf0d9759ccf5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:46:48 +0200 Subject: [PATCH 37/49] Remove check --- src/UIContainer.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 3e9bcad1..39b4b910 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -925,12 +925,10 @@ export class UIContainer extends HTMLElement { } private readonly _onTvKeyDown = (event: KeyboardEvent): void => { - if (this.deviceType === 'tv') { if (isBackKey(event.keyCode)) { this.setUserIdle_(); return; } - const focusedChild = getFocusedChild(getFocusableChildren(this)); if (this.isUserIdle_()) { // First button press should only make the UI visible @@ -944,7 +942,6 @@ export class UIContainer extends HTMLElement { event.preventDefault(); event.stopPropagation(); } - } }; private readonly _onKeyUp = (event: KeyboardEvent): void => { From 111671cc608f994a8e9ad3d8cb96487c16dd7c47 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:47:01 +0200 Subject: [PATCH 38/49] Fix indentation --- src/UIContainer.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 39b4b910..53926ec7 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -925,23 +925,23 @@ export class UIContainer extends HTMLElement { } private readonly _onTvKeyDown = (event: KeyboardEvent): void => { - if (isBackKey(event.keyCode)) { - this.setUserIdle_(); - return; - } - const focusedChild = getFocusedChild(getFocusableChildren(this)); - 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, getFocusableChildren(this), event.keyCode)) { - event.preventDefault(); - event.stopPropagation(); + if (isBackKey(event.keyCode)) { + this.setUserIdle_(); + return; + } + const focusedChild = getFocusedChild(getFocusableChildren(this)); + 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, getFocusableChildren(this), event.keyCode)) { + event.preventDefault(); + event.stopPropagation(); + } }; private readonly _onKeyUp = (event: KeyboardEvent): void => { From 1902c9bd4a15d3e62cdc1e5b12559db6da409f80 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:47:46 +0200 Subject: [PATCH 39/49] Extract variable --- src/UIContainer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 53926ec7..643793e1 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -929,7 +929,8 @@ export class UIContainer extends HTMLElement { this.setUserIdle_(); return; } - const focusedChild = getFocusedChild(getFocusableChildren(this)); + const focusableChildren = getFocusableChildren(this); + const focusedChild = getFocusedChild(focusableChildren); if (this.isUserIdle_()) { // First button press should only make the UI visible return; @@ -938,7 +939,7 @@ export class UIContainer extends HTMLElement { if (this._player !== undefined && focusedChild !== null) { focusedChild.click(); } - } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, getFocusableChildren(this), event.keyCode)) { + } else if (isArrowKey(event.keyCode) && navigateByArrowKey(this, focusableChildren, event.keyCode)) { event.preventDefault(); event.stopPropagation(); } From fffd9930726e71aa76ac69fcb48f33e2913f702b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Oct 2023 15:48:50 +0200 Subject: [PATCH 40/49] Tweak check --- src/UIContainer.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 643793e1..c0cf18c3 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -899,10 +899,12 @@ export class UIContainer extends HTMLElement { } this.setAttribute(Attribute.USER_IDLE, ''); - // Blur active element so that first key press on TV doesn't result in an action. - const focusedChild = getFocusedChild(getFocusableChildren(this)); - if (this.deviceType === 'tv' && focusedChild !== null && this.isUserIdle_()) { - focusedChild.blur(); + 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(getFocusableChildren(this)); + if (focusedChild !== null) { + focusedChild.blur(); + } } }; From af92e47c597f67abeae1dd14b0091fb13061d6cb Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 19 Oct 2023 14:09:01 +0200 Subject: [PATCH 41/49] Remove non-null assertion --- src/components/ChromecastButton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChromecastButton.ts b/src/components/ChromecastButton.ts index ace83cdb..2b844441 100644 --- a/src/components/ChromecastButton.ts +++ b/src/components/ChromecastButton.ts @@ -27,7 +27,7 @@ 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); From 71da7031eff5931638327162bd4fa9dbfa3b789a Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 19 Oct 2023 14:57:33 +0200 Subject: [PATCH 42/49] Move setting focus outside getFocusedChild --- src/UIContainer.ts | 11 +++++++++-- src/util/KeyboardNavigation.ts | 8 ++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index c0cf18c3..1895bb19 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -901,7 +901,7 @@ export class UIContainer extends HTMLElement { 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(getFocusableChildren(this)); + const focusedChild = getFocusedChild(); if (focusedChild !== null) { focusedChild.blur(); } @@ -932,7 +932,14 @@ export class UIContainer extends HTMLElement { return; } const focusableChildren = getFocusableChildren(this); - const focusedChild = getFocusedChild(focusableChildren); + let focusedChild = getFocusedChild(); + if (!focusedChild) { + if (focusableChildren.length > 0) { + focusableChildren[0].focus(); + focusedChild = focusableChildren[0]; + } + } + if (this.isUserIdle_()) { // First button press should only make the UI visible return; diff --git a/src/util/KeyboardNavigation.ts b/src/util/KeyboardNavigation.ts index e4b47d2a..319daf76 100644 --- a/src/util/KeyboardNavigation.ts +++ b/src/util/KeyboardNavigation.ts @@ -2,20 +2,16 @@ import { type ArrowKeyCode, KeyCode } from './KeyCode'; import { arrayMinByKey, getActiveElement, isHTMLElement } from './CommonUtils'; import { Rectangle } from './GeometryUtils'; -export function getFocusedChild(children: HTMLElement[]): HTMLElement | null { +export function getFocusedChild(): HTMLElement | null { const focusedChild = getActiveElement(); if (!focusedChild || focusedChild === document.body || !isHTMLElement(focusedChild)) { - if (children.length > 0) { - children[0].focus(); - return children[0]; - } return null; } return focusedChild; } export function navigateByArrowKey(container: HTMLElement, children: HTMLElement[], key: ArrowKeyCode): boolean { - const focusedChild = getFocusedChild(children); + const focusedChild = getFocusedChild(); if (focusedChild === null) { return false; } From 9e87261bcb52f0d72ac14a1c9631cf50cf55d908 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 19 Oct 2023 15:28:12 +0200 Subject: [PATCH 43/49] Allow setting a default focused child for TV --- src/DefaultUI.html | 2 +- src/UIContainer.ts | 9 ++++++--- src/util/Attribute.ts | 1 + src/util/CommonUtils.ts | 22 ++++++++++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/DefaultUI.html b/src/DefaultUI.html index cf2adb6e..4ec73339 100644 --- a/src/DefaultUI.html +++ b/src/DefaultUI.html @@ -29,7 +29,7 @@ - + diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 1895bb19..95ac9e91 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -7,6 +7,7 @@ import { arrayRemove, containsComposedNode, getActiveElement, + getTvFocusChildren, getFocusableChildren, isElement, isHTMLElement, @@ -931,12 +932,14 @@ export class UIContainer extends HTMLElement { this.setUserIdle_(); return; } + const tvFocusChildren = getTvFocusChildren(this); const focusableChildren = getFocusableChildren(this); let focusedChild = getFocusedChild(); if (!focusedChild) { - if (focusableChildren.length > 0) { - focusableChildren[0].focus(); - focusedChild = focusableChildren[0]; + const children = tvFocusChildren ?? focusableChildren; + if (children.length > 0) { + children[0].focus(); + focusedChild = children[0]; } } diff --git a/src/util/Attribute.ts b/src/util/Attribute.ts index 9f7001a6..d38a71fc 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -8,6 +8,7 @@ export enum Attribute { DEVICE_TYPE = 'device-type', MOBILE = 'mobile', TV = 'tv', + TV_FOCUS = 'tv-focus', MOBILE_ONLY = 'mobile-only', MOBILE_HIDDEN = 'mobile-hidden', USER_IDLE = 'user-idle', diff --git a/src/util/CommonUtils.ts b/src/util/CommonUtils.ts index 2c3932bd..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 { @@ -156,6 +158,26 @@ export function getChildren(element: Element): ArrayLike { 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); From 260a3e798906b83d1b5fa24d117e4f4b655cc08a Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Thu, 2 Nov 2023 13:26:59 +0100 Subject: [PATCH 44/49] Optimize import --- src/UIContainer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/UIContainer.ts b/src/UIContainer.ts index 95ac9e91..b071fc56 100644 --- a/src/UIContainer.ts +++ b/src/UIContainer.ts @@ -6,9 +6,8 @@ import { arrayFind, arrayRemove, containsComposedNode, - getActiveElement, - getTvFocusChildren, getFocusableChildren, + getTvFocusChildren, isElement, isHTMLElement, noOp, From fc0ee139e0b441e5cce3b7bebdeb1b49b58498ad Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 3 Nov 2023 11:50:34 +0100 Subject: [PATCH 45/49] Expose DeviceType --- src/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From e2fbdf798c67230c7c0e1a814242474c25692c68 Mon Sep 17 00:00:00 2001 From: Jeroen Veltmans Date: Fri, 3 Nov 2023 12:14:04 +0100 Subject: [PATCH 46/49] Hide fullscreen button on TV --- src/DefaultUI.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DefaultUI.html b/src/DefaultUI.html index 4ec73339..418ee10b 100644 --- a/src/DefaultUI.html +++ b/src/DefaultUI.html @@ -42,7 +42,7 @@ - + From 0420f201af1c502f5d368cf96a027241d7394421 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 3 Nov 2023 14:52:46 +0100 Subject: [PATCH 47/49] Set device type on default UI --- src/DefaultUI.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index d8339216..bb8e923c 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -6,6 +6,7 @@ import defaultUiHtml from './DefaultUI.html'; import { Attribute } from './util/Attribute'; import { applyExtensions } from './extensions/ExtensionRegistry'; 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'; @@ -230,10 +231,9 @@ export class DefaultUI extends HTMLElement { connectedCallback(): void { shadyCss.styleElement(this); - if (!this.hasAttribute(Attribute.MOBILE) && isMobile()) { - this.setAttribute(Attribute.MOBILE, ''); - } else if (!this.hasAttribute(Attribute.TV) && isTv()) { - this.setAttribute(Attribute.TV, ''); + if (!this.hasAttribute(Attribute.DEVICE_TYPE)) { + const deviceType: DeviceType = isMobile() ? 'mobile' : isTv() ? 'tv' : 'desktop'; + this.setAttribute(Attribute.DEVICE_TYPE, deviceType); } if (!this._appliedExtensions) { From 5ea8d9818e62bdbc55b598475305244e9a1c667d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 3 Nov 2023 14:53:15 +0100 Subject: [PATCH 48/49] Add tv-only and tv-hidden attributes --- src/DefaultUI.css | 8 +++++--- src/UIContainer.css | 8 ++++++++ src/util/Attribute.ts | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/DefaultUI.css b/src/DefaultUI.css index 73a0d155..f6f93e9d 100644 --- a/src/DefaultUI.css +++ b/src/DefaultUI.css @@ -161,10 +161,12 @@ theoplayer-ad-skip-button:not([disabled]) { } /* - * Tv-hidden elements + * TV-only and TV-hidden elements */ -theoplayer-ui[tv] [tv-hidden], -theoplayer-ui[tv] theoplayer-control-bar ::slotted([tv-hidden]) { +: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; } diff --git a/src/UIContainer.css b/src/UIContainer.css index 1f776ca7..472c8821 100644 --- a/src/UIContainer.css +++ b/src/UIContainer.css @@ -211,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/util/Attribute.ts b/src/util/Attribute.ts index d38a71fc..ea0fb303 100644 --- a/src/util/Attribute.ts +++ b/src/util/Attribute.ts @@ -11,6 +11,8 @@ export enum Attribute { 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', From 5041068e4734d62c59eff351ed2e0896fd3abe61 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 3 Nov 2023 15:00:20 +0100 Subject: [PATCH 49/49] Update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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.