Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/theolive UI #58

Merged
merged 20 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<theolive-default-ui>` that provides a default UI for THEOlive streams. ([#58](https://github.com/THEOplayer/web-ui/pull/58))

## v1.7.2 (2024-03-18)

Expand Down
1 change: 1 addition & 0 deletions react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<THEOliveDefaultUI>` that provides a default UI for THEOlive streams. ([#58](https://github.com/THEOplayer/web-ui/pull/58))

## v1.7.2 (2024-03-18)

Expand Down
60 changes: 60 additions & 0 deletions react/src/THEOliveDefaultUI.tsx
Original file line number Diff line number Diff line change
@@ -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<WebComponentProps<THEOliveDefaultUIElement>> {
/**
* 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<THEOliveDefaultUIElement | null>(null);
const player = usePlayer(ui, onReady);
return (
<RawTHEOliveDefaultUI {...otherProps} ref={setUi}>
<PlayerContext.Provider value={player}>
{loadingAnnouncement && <SlotContainer slot="loading-announcement">{loadingAnnouncement}</SlotContainer>}
{offlineAnnouncement && <SlotContainer slot="offline-announcement">{offlineAnnouncement}</SlotContainer>}
</PlayerContext.Provider>
</RawTHEOliveDefaultUI>
);
};
1 change: 1 addition & 0 deletions react/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
20 changes: 20 additions & 0 deletions react/test/ssr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,24 @@ describe('Server-side rendering (SSR)', () => {
'</theoplayer-ui>';
assert.equal(actual, expected);
});

it('can render <THEOliveDefaultUI> 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 =
'<theolive-default-ui>' +
'<theoplayer-slot-container slot="loading-announcement">Loading</theoplayer-slot-container>' +
'<theoplayer-slot-container slot="offline-announcement">Offline</theoplayer-slot-container>' +
'</theolive-default-ui>';
assert.equal(actual, expected);
});
});
34 changes: 22 additions & 12 deletions src/DefaultUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<HTMLSlotElement>('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');
Expand All @@ -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];
Expand Down Expand Up @@ -304,16 +310,20 @@ 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 = () => {
this.dispatchEvent(createCustomEvent(READY_EVENT));
};

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

Expand Down
213 changes: 213 additions & 0 deletions src/THEOliveDefaultUI.css
Original file line number Diff line number Diff line change
@@ -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 <video> controls.
* See: https://bugs.chromium.org/p/chromium/issues/detail?id=1404684
*/
/* prettier-ignore */
--theoplayer-control-background-gradient-stops: rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.01) 8.1%,
rgba(0, 0, 0, 0.037) 15.5%,
rgba(0, 0, 0, 0.078) 22.5%,
rgba(0, 0, 0, 0.131) 29%,
rgba(0, 0, 0, 0.195) 35.3%,
rgba(0, 0, 0, 0.264) 41.2%,
rgba(0, 0, 0, 0.337) 47.1%,
rgba(0, 0, 0, 0.413) 52.9%,
rgba(0, 0, 0, 0.486) 58.8%,
rgba(0, 0, 0, 0.555) 64.7%,
rgba(0, 0, 0, 0.619) 71%,
rgba(0, 0, 0, 0.672) 77.5%,
rgba(0, 0, 0, 0.713) 84.5%,
rgba(0, 0, 0, 0.74) 91.9%,
rgba(0, 0, 0, 0.75) 100%;
}

:host(:not([mobile])) [part='top-chrome']::before,
:host(:not([mobile])) [part='bottom-chrome']::before {
content: '';
display: block;
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}

:host(:not([mobile])) [part='top-chrome']::before {
background: linear-gradient(to top, var(--theoplayer-control-background-gradient-stops));
}

:host(:not([mobile])) [part='bottom-chrome']::before {
background: linear-gradient(to bottom, var(--theoplayer-control-background-gradient-stops));
}

.theoplayer-spacer {
flex-grow: 1;
}

theoplayer-time-range {
--theoplayer-control-height: 12px;
--theoplayer-range-track-pointer-background: rgba(255, 255, 255, 0.5);
}

/*
* Mobile-only and mobile-hidden elements
*/
:host([mobile]) [mobile-hidden],
:host(:not([mobile])) [mobile-only] {
display: none !important;
}

/*
* Live-only and live-hidden elements
*/
:host(:not([stream-type='vod'])) [live-hidden],
:host(:not([stream-type='vod'])) theoplayer-control-bar ::slotted([live-hidden]),
:host([stream-type='vod']) [live-only],
:host([stream-type='vod']) theoplayer-control-bar ::slotted([live-only]) {
display: none !important;
}

/*
* Ad-only and ad-hidden elements
*/
theoplayer-ui[playing-ad] [ad-hidden],
theoplayer-ui:not([playing-ad]) [ad-only] {
display: none !important;
}

/*
* Hide all controls before first play, except for the center play button
*/
theoplayer-ui:not([has-first-play]) theoplayer-control-bar,
theoplayer-ui:not([has-first-play]) [part='centered-chrome'] :not(theoplayer-play-button) {
display: none !important;
}

/*
* Hide center play button on desktop after first play
*/
:host(:not([mobile])) theoplayer-ui[has-first-play] [part='centered-chrome'] theoplayer-play-button {
display: none !important;
}

theoplayer-volume-range {
--theoplayer-range-padding-left: 0;
}

theoplayer-mute-button + theoplayer-volume-range {
width: 0;
overflow: hidden;
--theoplayer-range-padding-right: 0;

/* Set the internal width so it reveals, not grows */
--theoplayer-range-track-width: 70px;
transition: width 0.2s ease-in;
}

/* Expand volume control in all relevant states */
theoplayer-mute-button:hover + theoplayer-volume-range,
theoplayer-mute-button:focus + theoplayer-volume-range,
theoplayer-mute-button + theoplayer-volume-range:hover,
theoplayer-mute-button + theoplayer-volume-range:focus {
width: 70px;
}

/* IE doesn't support :focus-within, so keep these separate (and use a polyfill?) */
theoplayer-mute-button:focus-within + theoplayer-volume-range,
theoplayer-mute-button + theoplayer-volume-range:focus-within {
width: 70px;
}

/* Reduce space between live button and remaining time display */
theoplayer-live-button + theoplayer-time-display {
padding-left: 0;
}

/* Hide remaining time display when playing at live edge */
theoplayer-live-button[live] + theoplayer-time-display {
display: none !important;
}

p {
color: var(--theoplayer-text-color, #fff);
font-size: var(--theoplayer-text-font-size, 20px);
}

#loading-announcement {
display: none;
}

#offline-announcement {
display: none;
}

#announcement {
display: none;
}

theolive-bad-network-button {
display: var(--theolive-bad-network-button-display, none);
}

theoplayer-settings-menu-button {
display: var(--theolive-quality-button-display, inline-flex);
}
Loading