diff --git a/README.md b/README.md index c27aed1..e729c1d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,18 @@ Once you have uploaded your first video you can show your videos in different fo You can either change the settings through our interface or provide it as attributes. To learn which attributes you can use to change the appearance of your player, go to [our docs](https://docs.mave.io). +For a space with JWT playback enabled, set the customer JWT as a JavaScript +property. The component exchanges it once for an embed-scoped media session; +the customer JWT is never added to media URLs or reflected to an HTML attribute. + +```js +const player = document.querySelector('mave-player'); +player.token = customerJwt; +``` + +The same non-reflecting `token` property is available on `mave-clip`, +`mave-img`, `mave-text`, `mave-files`, `mave-list`, and `mave-pop`. + ### Clip ```html @@ -108,12 +120,16 @@ We often find ourselves using simple `.mp4` files, because we just want to show ### List ```html - + + + ``` Screenshot 2023-05-22 at 15 37 55 diff --git a/src/components/clip.ts b/src/components/clip.ts index 9eb0716..f062e6b 100644 --- a/src/components/clip.ts +++ b/src/components/clip.ts @@ -67,7 +67,7 @@ export class Clip extends LitElement { } private _token: string; - @property() + @property({ attribute: false }) get token(): string { return this._token; } diff --git a/src/components/files.ts b/src/components/files.ts index fb974bc..3cb6a97 100644 --- a/src/components/files.ts +++ b/src/components/files.ts @@ -19,6 +19,20 @@ export class Files extends MaveElement { } } + private _token: string; + @property({ attribute: false }) + get token(): string { + return this._token; + } + + set token(value: string) { + if (this._token !== value) { + this._token = value; + this.requestUpdate('token'); + this.embedController.token = value; + } + } + get _slottedChildren() { const slot = this.shadowRoot?.querySelector('slot'); return slot?.assignedElements({ flatten: true }) || []; @@ -319,7 +333,7 @@ export class Files extends MaveElement { this._data?.video?.version && this._data.video.version > 0 ? `v${this._data.video.version}/` : ''; - return `${this.cdn_root}/${this.embedId}/${versionSegment}${filename}`; + return this.embedController.embedFile(`${versionSegment}${filename}`); } #buildVideoDownloadUrl(rendition?: Rendition): string { diff --git a/src/components/img.ts b/src/components/img.ts index 6888e12..fdc7a81 100644 --- a/src/components/img.ts +++ b/src/components/img.ts @@ -1,7 +1,45 @@ +import { Task } from '@lit/task'; import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { PlaybackSession, playbackSession } from '../embed/playback'; import { MaveElement } from '../utils/mave_element'; export class Image extends MaveElement { + private _token: string; + private sessionTask = new Task(this, { + task: async ([embed, token]) => { + if (!embed || !token) return; + const session = await playbackSession(token, embed); + if (this.embed !== embed || this.token !== token) return; + return session; + }, + args: () => [this.embed, this.token] as const, + }); + + @property({ type: String }) + get embed(): string { + return this._embed; + } + + set embed(value: string) { + if (this._embed !== value) { + this._embed = value; + this.requestUpdate('embed'); + } + } + + @property({ attribute: false }) + get token(): string { + return this._token; + } + + set token(value: string) { + if (this._token !== value) { + this._token = value; + this.requestUpdate('token'); + } + } + static styles = css` :host { display: block; @@ -13,12 +51,21 @@ export class Image extends MaveElement { } `; - get poster(): string { - return `${this.cdn_root}/${this.embedId}/poster.webp`; + poster(session?: PlaybackSession): string { + if (!session) return `${this.cdn_root}/${this.embedId}/poster.webp`; + const url = new URL(`${session.media_base_url.replace(/\/$/, '')}/poster.webp`); + url.searchParams.set('token', session.token); + return url.toString(); } render() { - return html``; + if (!this.token) return html``; + + return html`${this.sessionTask.render({ + pending: () => html``, + error: () => html``, + complete: (session) => html``, + })}`; } } diff --git a/src/components/list.ts b/src/components/list.ts index 21f3a28..7259313 100644 --- a/src/components/list.ts +++ b/src/components/list.ts @@ -7,7 +7,19 @@ import { MaveElement } from '../utils/mave_element'; import { checkPop } from './pop.js'; export class List extends MaveElement { - @property() token: string; + private _token: string; + @property({ attribute: false }) + get token(): string { + return this._token; + } + + set token(value: string) { + if (this._token !== value) { + this._token = value; + this.embedController.token = value; + this.requestUpdate('token'); + } + } @property() order?: 'oldest' | 'newest' | 'az' | 'za' = 'newest'; static styles = css` @@ -24,13 +36,6 @@ export class List extends MaveElement { this.embedController.token = this.token; } - requestUpdate(name?: PropertyKey, oldValue?: unknown) { - super.requestUpdate(name, oldValue); - if (name === 'embed') { - this.embedController.token = this.token; - } - } - get _slottedChildren() { const slot = this.shadowRoot?.querySelector('slot'); return slot?.assignedElements({ flatten: true }) || []; @@ -166,6 +171,11 @@ export class List extends MaveElement { this.#setEmbedAttribute(template, 'mave-clip', video.id); this.#setEmbedAttribute(template, 'mave-player', video.id); this.#setEmbedAttribute(template, 'mave-img', video.id); + this.#setTokenProperty(template, 'mave-clip'); + this.#setTokenProperty(template, 'mave-player'); + this.#setTokenProperty(template, 'mave-img'); + this.#setTokenProperty(template, 'mave-text'); + this.#setTokenProperty(template, 'mave-files'); const clip = template.querySelector('mave-clip'); const title = this.#querySlotElement(template, 'item-title'); @@ -229,6 +239,14 @@ export class List extends MaveElement { this.#clearSlotAttributes(element); } + #setTokenProperty(template: DocumentFragment, selector: string) { + const element = template.querySelector(selector) as + | (HTMLElement & { token?: string }) + | null; + if (!element || !this.token) return; + element.token = this.token; + } + #setTextContent(template: DocumentFragment, slotName: string, text: string) { const element = this.#querySlotElement(template, slotName); if (!element) return; diff --git a/src/components/player.ts b/src/components/player.ts index 812432a..f5cb687 100644 --- a/src/components/player.ts +++ b/src/components/player.ts @@ -98,7 +98,7 @@ export class Player extends MaveElement { } private _token: string; - @property() + @property({ attribute: false }) get token(): string { return this._token; } @@ -821,13 +821,7 @@ export class Player extends MaveElement { } #xhrHLSSetup(xhr: XMLHttpRequest, url: string) { - const newUrl = new URL(url); - if (this.token && !newUrl.searchParams.get('token')) { - const params = new URLSearchParams(); - params.append('token', this.token); - newUrl.search = params.toString(); - } - xhr.open('GET', newUrl.toString()); + xhr.open('GET', url); } connectedCallback(): void { @@ -1297,7 +1291,7 @@ export class Player extends MaveElement { return this.#manifestPoster(); } - return `https://image.mave.io/${this.embedController.spaceId}${this.embedController.embedId}.jpg?time=${time}`; + return this.embedController.dynamicImage(time); } #hasPosterValue(value: string | number | null | undefined): value is string | number { diff --git a/src/components/pop.ts b/src/components/pop.ts index a50f244..6c46cfb 100644 --- a/src/components/pop.ts +++ b/src/components/pop.ts @@ -12,6 +12,20 @@ export class Pop extends LitElement { @query('.backdrop') _backdrop: HTMLElement; private _player?: Player; + private _token: string; + + @property({ attribute: false }) + get token(): string { + return this._token; + } + + set token(value: string) { + if (this._token !== value) { + this._token = value; + if (this._player) this._player.token = value; + this.requestUpdate('token'); + } + } static styles = css` :host { @@ -173,6 +187,7 @@ export class Pop extends LitElement { open(player: Player) { this._player = player; + if (this.token !== undefined) player.token = this.token; if (player.aspect_ratio) { const [w, h] = player.aspect_ratio.split('/'); this.style.setProperty('--frame-ratio-w', w); diff --git a/src/components/text.ts b/src/components/text.ts index c1af2cb..2332b10 100644 --- a/src/components/text.ts +++ b/src/components/text.ts @@ -23,11 +23,26 @@ export class Text extends LitElement { if (this._embedId != value) { this._embedId = value; this.captionController = new CaptionController(this, this.embed); + this.captionController.token = this.token; this.reset(); this.requestUpdate('embed'); } } + private _token: string; + @property({ attribute: false }) + get token(): string { + return this._token; + } + + set token(value: string) { + if (this._token !== value) { + this._token = value; + if (this.captionController) this.captionController.token = value; + this.requestUpdate('token'); + } + } + @property() highlight: string; @property({ attribute: 'highlight-mode' }) highlightMode: HighlightMode = 'sentence'; @property({ attribute: 'transcript-label' }) transcriptLabel: string = 'Transcript'; diff --git a/src/embed/caption.ts b/src/embed/caption.ts index 1abe6ef..01328fc 100644 --- a/src/embed/caption.ts +++ b/src/embed/caption.ts @@ -3,6 +3,7 @@ import { ReactiveControllerHost } from 'lit'; import { Config } from '../config'; import * as API from './api'; +import { PlaybackSession, playbackSession } from './playback'; // More compatible version of: // const regex = /(? { try { + const requestEmbed = this.embed; + const requestToken = this.token; + const session = requestToken + ? await playbackSession(requestToken, requestEmbed) + : undefined; + if (this.embed !== requestEmbed || this.token !== requestToken) return; + this._playbackSession = session; + const url = this.embedFile('subtitle.json'); const response = await fetch(url); @@ -162,13 +172,14 @@ export class CaptionController { throw new Error(); } }, - () => [this.embed], + () => [this.embed, this.token], ); } set embed(value: string) { if (this._embed != value) { this._embed = value; + this._playbackSession = undefined; this.host.requestUpdate(); } } @@ -180,6 +191,7 @@ export class CaptionController { set token(value: string) { if (this._token != value) { this._token = value; + this._playbackSession = undefined; this.host.requestUpdate(); } } @@ -215,12 +227,13 @@ export class CaptionController { } embedFile(file: string, params = new URLSearchParams()): string { + const mediaBaseUrl = this._playbackSession?.media_base_url.replace(/\/$/, ''); const url = new URL( - `${this.cdnRoot}/${this.embedId}${ + `${mediaBaseUrl || `${this.cdnRoot}/${this.embedId}`}${ file == 'manifest.json' ? '/' : this.version }${file}`, ); - if (this.token) params.append('token', this.token); + if (this._playbackSession) params.append('token', this._playbackSession.token); url.search = params.toString(); return url.toString(); } diff --git a/src/embed/controller.ts b/src/embed/controller.ts index 894c9f3..4df3255 100644 --- a/src/embed/controller.ts +++ b/src/embed/controller.ts @@ -3,6 +3,7 @@ import { ReactiveControllerHost } from 'lit'; import { Config } from '../config'; import * as API from './api'; +import { PlaybackSession, playbackSession } from './playback'; export enum EmbedType { Collection, @@ -20,6 +21,7 @@ export class EmbedController { private type: EmbedType; private _embed: string; private _token: string; + private _playbackSession?: PlaybackSession; private _version: number; private _enabled: boolean; private _embedData: Partial; @@ -54,6 +56,14 @@ export class EmbedController { const requestEmbed = this.embed; const requestToken = this.token; + const session = + this.type == EmbedType.Embed && requestToken + ? await playbackSession(requestToken, requestEmbed) + : undefined; + + if (this.embed !== requestEmbed || this.token !== requestToken) return; + this._playbackSession = session; + const data = await this.fetchManifestWithRetry(() => { if (!this.enabled) return; if (this.embed !== requestEmbed || this.token !== requestToken) return; @@ -95,7 +105,7 @@ export class EmbedController { if (!url) return; try { - const response = await fetch(url); + const response = await fetch(url, this.fetchOptions); if (!response.ok) { throw EmbedController.responseError(response, url); } @@ -151,7 +161,7 @@ export class EmbedController { if (this.type == EmbedType.Embed) { return this.embedFile('manifest.json'); } else { - const url = new URL(`${API.baseUrl()}/collection/${this.token}`); + const url = new URL(`${API.baseUrl().replace(/\/$/, '')}/collection`); if (this.embed && this.embed?.length > 1) url.searchParams.append('embed', this.embed); return url.toString(); @@ -161,6 +171,7 @@ export class EmbedController { set embed(value: string) { if (this._embed != value) { this._embed = value; + this._playbackSession = undefined; this.loading = true; this.host.requestUpdate(); } @@ -173,6 +184,7 @@ export class EmbedController { set token(value: string) { if (this._token != value) { this._token = value; + this._playbackSession = undefined; this.host.requestUpdate(); } } @@ -219,6 +231,17 @@ export class EmbedController { return Config.cdn.endpoint.replace('${this.spaceId}', this.spaceId); } + private get fetchOptions(): RequestInit | undefined { + if (this.type != EmbedType.Collection || !this.token) return undefined; + + return { + headers: { Authorization: `Bearer ${this.token}` }, + cache: 'no-store', + credentials: 'omit', + referrerPolicy: 'no-referrer', + }; + } + refresh(): Promise { this.enabled = true; this.loading = true; @@ -226,8 +249,9 @@ export class EmbedController { } embedFile(file: string, params = new URLSearchParams()): string { + const mediaBaseUrl = this._playbackSession?.media_base_url.replace(/\/$/, ''); const url = new URL( - `${this.cdnRoot}/${this.embedId}${ + `${mediaBaseUrl || `${this.cdnRoot}/${this.embedId}`}${ file == 'manifest.json' ? '/' : this.version }${file}`, ); @@ -240,7 +264,7 @@ export class EmbedController { }); } - if (this.token) params.append('token', this.token); + if (this._playbackSession) params.append('token', this._playbackSession.token); if (file == 'manifest.json') { if (params.has('e')) params.delete('e'); params.append('e', new Date().getTime().toString()); @@ -255,6 +279,19 @@ export class EmbedController { return url.toString(); } + dynamicImage(time: string | number, format = 'jpg'): string { + if (!this._playbackSession) { + return `https://image.mave.io/${this.spaceId}${this.embedId}.${format}?time=${time}`; + } + + const url = new URL( + `${this._playbackSession.media_base_url.replace(/\/$/, '')}/dynamic.${format}`, + ); + url.searchParams.set('time', String(time)); + url.searchParams.set('token', this._playbackSession.token); + return url.toString(); + } + render(renderFunctions: StatusRenderer) { return this.task?.render(renderFunctions); } diff --git a/src/embed/playback.ts b/src/embed/playback.ts new file mode 100644 index 0000000..8bc01f0 --- /dev/null +++ b/src/embed/playback.ts @@ -0,0 +1,73 @@ +import { Config } from '../config'; + +export type PlaybackSession = { + token: string; + expires_at: number; + media_base_url: string; +}; + +type CachedSession = { + session?: PlaybackSession; + promise?: Promise; +}; + +const sessions = new Map(); +const EXPIRY_LEEWAY_SECONDS = 60; + +function cacheKey(customerToken: string, embed: string) { + return `${customerToken}\u0000${embed}`; +} + +function sessionEndpoint() { + return `${Config.api.endpoint.replace(/\/$/, '')}/playback/sessions`; +} + +export async function playbackSession( + customerToken: string, + embed: string, +): Promise { + const key = cacheKey(customerToken, embed); + const cached = sessions.get(key); + const now = Math.floor(Date.now() / 1000); + + if (cached?.session && cached.session.expires_at > now + EXPIRY_LEEWAY_SECONDS) { + return cached.session; + } + if (cached?.promise) return cached.promise; + + const promise = fetch(sessionEndpoint(), { + method: 'POST', + headers: { + Authorization: `Bearer ${customerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ embed }), + cache: 'no-store', + credentials: 'omit', + referrerPolicy: 'no-referrer', + }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Playback session request failed with status ${response.status}`); + } + + const session = (await response.json()) as PlaybackSession; + if (!session.token || !session.media_base_url || !session.expires_at) { + throw new Error('Playback session response is incomplete'); + } + + sessions.set(key, { session }); + return session; + }) + .catch((error) => { + sessions.delete(key); + throw error; + }); + + sessions.set(key, { promise }); + return promise; +} + +export function clearPlaybackSessions() { + sessions.clear(); +} diff --git a/src/vue.ts b/src/vue.ts index 91de382..402c82e 100644 --- a/src/vue.ts +++ b/src/vue.ts @@ -85,6 +85,11 @@ function normalizeAttributes(attrs: Record) { continue; } + if (key === 'token') { + forwarded[key] = value; + continue; + } + if (value === false || value === undefined || value === null) { forwarded[`^${key}`] = null; continue;