diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3cc318..55451423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ sidebar_custom_props: { 'icon': '📰' } ## Unreleased - 💥 **Breaking Change**: This project now requires THEOplayer version 7.0.0 or higher. ([#60](https://github.com/THEOplayer/web-ui/pull/60)) +- 🚀 Added `` that provides a default UI for THEOlive streams. ([#58](https://github.com/THEOplayer/web-ui/pull/58)) ## v1.7.2 (2024-03-18) diff --git a/react/CHANGELOG.md b/react/CHANGELOG.md index cb4d793d..da1c5519 100644 --- a/react/CHANGELOG.md +++ b/react/CHANGELOG.md @@ -18,6 +18,7 @@ sidebar_custom_props: { 'icon': '📰' } ## Unreleased - 💥 **Breaking Change**: This project now requires THEOplayer version 7.0.0 or higher. ([#60](https://github.com/THEOplayer/web-ui/pull/60)) +- 🚀 Added `` that provides a default UI for THEOlive streams. ([#58](https://github.com/THEOplayer/web-ui/pull/58)) ## v1.7.2 (2024-03-18) diff --git a/react/src/THEOliveDefaultUI.tsx b/react/src/THEOliveDefaultUI.tsx new file mode 100644 index 00000000..3ebae86c --- /dev/null +++ b/react/src/THEOliveDefaultUI.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { type PropsWithoutRef, type ReactNode, useState } from 'react'; +import { THEOliveDefaultUI as THEOliveDefaultUIElement } from '@theoplayer/web-ui'; +import type { ChromelessPlayer } from 'theoplayer/chromeless'; +import { createComponent, type WebComponentProps } from '@lit/react'; +import { usePlayer } from './util'; +import { PlayerContext } from './context'; +import { SlotContainer } from './components'; + +const RawTHEOliveDefaultUI = createComponent({ + tagName: 'theolive-default-ui', + displayName: 'THEOliveDefaultUI', + elementClass: THEOliveDefaultUIElement, + react: React, + events: { + onReady: 'theoplayerready' + } as const +}); + +export interface THEOliveDefaultUIProps extends PropsWithoutRef> { + /** + * A slot for the loading announcement, shown before the publication is loaded. + */ + loadingAnnouncement?: ReactNode; + /** + * A slot for the offline announcement, shown when all publications are offline. + */ + offlineAnnouncement?: ReactNode; + /** + * Use a named slot instead, such as: + * - {@link loadingAnnouncement} + * - {@link offlineAnnouncement} + */ + children?: never; + /** + * Called when the backing player is created. + * + * @param player + */ + onReady?: (player: ChromelessPlayer) => void; +} + +/** + * A default UI for THEOlive. + * + * @group Components + */ +export const THEOliveDefaultUI = (props: THEOliveDefaultUIProps) => { + const { loadingAnnouncement, offlineAnnouncement, onReady, ...otherProps } = props; + const [ui, setUi] = useState(null); + const player = usePlayer(ui, onReady); + return ( + + + {loadingAnnouncement && {loadingAnnouncement}} + {offlineAnnouncement && {offlineAnnouncement}} + + + ); +}; diff --git a/react/src/index.ts b/react/src/index.ts index 12889e73..ee176a7e 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -1,6 +1,7 @@ export { PlayerContext } from './context'; export * from './UIContainer'; export * from './DefaultUI'; +export * from './THEOliveDefaultUI'; export * from './components/index'; export * from './hooks/index'; export * from './version'; diff --git a/react/test/ssr.test.mjs b/react/test/ssr.test.mjs index b135819a..04ce2096 100644 --- a/react/test/ssr.test.mjs +++ b/react/test/ssr.test.mjs @@ -48,4 +48,24 @@ describe('Server-side rendering (SSR)', () => { ''; assert.equal(actual, expected); }); + + it('can render to string', async () => { + const { THEOliveDefaultUI, PlayButton, TimeRange } = await import('@theoplayer/react-ui'); + const actual = renderToString( + React.createElement(THEOliveDefaultUI, { + // Properties are ignored during SSR + configuration: { libraryLocation: 'foo', license: 'bar' }, + onReady: () => console.log('ready!'), + // Slots are inserted as elements + loadingAnnouncement: 'Loading', + offlineAnnouncement: 'Offline' + }) + ); + const expected = + '' + + 'Loading' + + 'Offline' + + ''; + assert.equal(actual, expected); + }); }); diff --git a/src/DefaultUI.ts b/src/DefaultUI.ts index ae218761..ba249b66 100644 --- a/src/DefaultUI.ts +++ b/src/DefaultUI.ts @@ -86,9 +86,10 @@ export class DefaultUI extends HTMLElement { ]; } - private readonly _ui: UIContainer; - private readonly _titleSlot: HTMLSlotElement; - private readonly _timeRange: TimeRange; + protected readonly _shadowRoot: ShadowRoot; + protected readonly _ui: UIContainer; + private readonly _titleSlot: HTMLSlotElement | undefined; + private readonly _timeRange: TimeRange | undefined; private _appliedExtensions: boolean = false; /** @@ -101,18 +102,17 @@ export class DefaultUI extends HTMLElement { */ constructor(configuration: PlayerConfiguration = {}) { super(); - const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); - shadowRoot.appendChild(template().content.cloneNode(true)); + this._shadowRoot = this.initShadowRoot(); - this._ui = shadowRoot.querySelector('theoplayer-ui')!; + this._ui = this._shadowRoot.querySelector('theoplayer-ui')!; this._ui.addEventListener(READY_EVENT, this._dispatchReadyEvent); this._ui.addEventListener(STREAM_TYPE_CHANGE_EVENT, this._updateStreamType); this.setConfiguration_(configuration); - this._titleSlot = shadowRoot.querySelector('slot[name="title"]')!; - this._titleSlot.addEventListener('slotchange', this._onTitleSlotChange); + this._titleSlot = this._shadowRoot.querySelector('slot[name="title"]') ?? undefined; + this._titleSlot?.addEventListener('slotchange', this._onTitleSlotChange); - this._timeRange = shadowRoot.querySelector('theoplayer-time-range')!; + this._timeRange = this._shadowRoot.querySelector('theoplayer-time-range') ?? undefined; this._upgradeProperty('configuration'); this._upgradeProperty('source'); @@ -124,6 +124,12 @@ export class DefaultUI extends HTMLElement { this._upgradeProperty('dvrThreshold'); } + protected initShadowRoot(): ShadowRoot { + const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); + shadowRoot.appendChild(template().content.cloneNode(true)); + return shadowRoot; + } + private _upgradeProperty(prop: keyof this) { if (this.hasOwnProperty(prop)) { let value = this[prop]; @@ -304,8 +310,10 @@ export class DefaultUI extends HTMLElement { private readonly _updateStreamType = () => { this.setAttribute(Attribute.STREAM_TYPE, this.streamType); - // Hide seekbar when stream is live with no DVR - toggleAttribute(this._timeRange, Attribute.HIDDEN, this.streamType === 'live'); + if (this._timeRange) { + // Hide seekbar when stream is live with no DVR + toggleAttribute(this._timeRange, Attribute.HIDDEN, this.streamType === 'live'); + } }; private readonly _dispatchReadyEvent = () => { @@ -313,7 +321,9 @@ export class DefaultUI extends HTMLElement { }; private readonly _onTitleSlotChange = () => { - toggleAttribute(this, Attribute.HAS_TITLE, this._titleSlot.assignedNodes().length > 0); + if (this._titleSlot) { + toggleAttribute(this, Attribute.HAS_TITLE, this._titleSlot.assignedNodes().length > 0); + } }; } diff --git a/src/THEOliveDefaultUI.css b/src/THEOliveDefaultUI.css new file mode 100644 index 00000000..e7fb4b36 --- /dev/null +++ b/src/THEOliveDefaultUI.css @@ -0,0 +1,213 @@ +:host { + box-sizing: border-box; + position: relative; + display: inline-block; + width: 100%; + font-family: Helvetica, Arial, sans-serif; +} + +:host([hidden]) { + display: none !important; +} + +theoplayer-ui { + font-family: Helvetica, Arial, sans-serif; + width: 100%; + --theoplayer-loading-delay: 0.1s; +} + +:host(:fullscreen), +:host(:fullscreen) theoplayer-ui { + width: 100% !important; + height: 100% !important; +} + +theoplayer-menu::part(heading) { + display: none !important; +} + +[part='centered-chrome'] * { + --theoplayer-control-height: 48px; +} + +[part='middle-chrome'] { + position: relative; + display: flex; + flex-flow: column nowrap; + /* Align to bottom for Chromecast display */ + justify-content: flex-end; + flex-grow: 1; + pointer-events: none; +} + +[part='bottom-chrome'] { + position: relative; + display: flex; + flex-flow: column nowrap; + align-items: stretch; +} + +/* + * On mobile, put a backdrop color on the entire player when showing controls. + */ +:host([mobile]) theoplayer-ui { + --theoplayer-control-backdrop-background: rgba(0, 0, 0, 0.5); +} + +/* + * On desktop, put a soft gradient behind the top and bottom control bars. + */ +:host { + /* + * Smooth transparent-to-black gradient from Chrome's