diff --git a/apps/signage/USER_STORIES.md b/apps/signage/USER_STORIES.md index 206c05ed9a2..bd7f646f6ff 100644 --- a/apps/signage/USER_STORIES.md +++ b/apps/signage/USER_STORIES.md @@ -156,8 +156,8 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device **Acceptance Criteria:** -- Webpage items render in an iframe. -- Playback timing starts after the iframe load event plus a 2 second reveal delay. +- Webpage items render in a sandboxed iframe. The page can run scripts, use its own origin and submit forms. It cannot navigate the player, open popups or show dialogs. +- Playback timing starts after the iframe load event plus a 3 second reveal delay. - If a webpage never reports load, playback continues after a 15 second wait. - Webpage items play for their configured effective duration. - A single valid webpage item remains loaded instead of reloading on every loop. @@ -180,13 +180,13 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device - When a plugin reports `ready`, the player sends config and then a play signal. - If a plugin does not report load or ready, the player sends config after a 15 second wait. - Static plugins follow the configured effective duration. -- Play-through plugins advance when they report `finished`. +- Play-through plugins advance when they report `finished`. If the plugin is the only item, the player sends it a new play signal instead. - A play-through plugin that never sends a plugin message advances after its configured duration, like a static plugin. - If that plugin is the only item, it is removed from the screen and loaded again after 30 seconds. - A play-through plugin that does not report `finished` advances after twice its configured duration, but not before 5 minutes and not after 60 minutes. - Interactive plugins can request a new playback duration through plugin interaction events. - Upcoming plugin items can be preloaded on the inactive output shortly before transition. -- Fatal plugin errors advance to the next media item. +- Fatal plugin errors from the plugin on screen advance to the next media item. Errors from a plugin that is preloaded for later do not change the item on screen. --- @@ -220,7 +220,7 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device - Playback starts at the first valid item when the player has a playlist. - Items advance automatically when their effective duration expires. - The player skips media that is not currently valid. -- When a changed playlist still contains the currently playing item, the current item is held over before the updated playlist continues. +- When a changed playlist still contains the currently playing item unchanged, that item keeps playing without a restart, and the updated playlist continues after it. If the item changed (for example, a new source), it starts again. - If no valid items exist, the player retries item selection every 5 seconds. - If media URL resolution hangs or fails, the player waits briefly, skips the failed item when possible, and retries the playlist after a short delay when every valid item has failed. @@ -268,7 +268,7 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device - Debug mode shows play or pause, previous, next, mute, loop, and shuffle controls. - Play and pause preserve current item progress. - Previous and next move to valid playlist items. -- Mute and unmute update the active video element. +- Mute and unmute update the video elements of the player and of any other player on the display, such as an override. - A progress bar shows playback progress and exposes elapsed duration through its tooltip. --- @@ -518,6 +518,9 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device - A media count is recorded when a valid item advances after more than 50% progress. - A playlist count is recorded when playback advances beyond the last valid item for a playlist. +- Counts are recorded only when playback moves on. Retries of the same item, such as while it waits for its media, and selecting an item or going back in debug mode, record nothing. +- A lone webpage or plugin that is held on screen is credited with one pass when its duration has passed, and is not credited again while it stays. A single-pass takeover whose only item is held therefore ends after one pass. +- A play-through plugin that reports `finished` is credited as fully played, however early it finished. - A playlist play-through count is recorded when the last valid item for a playlist advances after more than 50% progress. - Playlist counts and play-through counts are not stored for random playlists. - Metrics are tracked separately for media counts, playlist counts, and play-through counts. diff --git a/apps/signage/src/app/media-controls.component.ts b/apps/signage/src/app/media-controls.component.ts index d69ddbb6685..0e74b921e5a 100644 --- a/apps/signage/src/app/media-controls.component.ts +++ b/apps/signage/src/app/media-controls.component.ts @@ -2,12 +2,12 @@ import { Component, ElementRef, input, - model, OnDestroy, OnInit, output, viewChild, } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -20,7 +20,8 @@ import { time } from './media-helpers'; import { MediaPlayerState } from './types'; type MediaLoop = 'NONE' | 'ONE' | 'ALL'; -type MediaEvent = +/** Commands the playback controls send to the player */ +export type MediaControlEvent = | 'PLAY' | 'PAUSE' | 'PREVIOUS' @@ -232,6 +233,7 @@ export class MediaProgressBarComponent implements OnInit, OnDestroy { imports: [ IconComponent, TranslatePipe, + MatRippleModule, MatTooltipModule, MatProgressSpinnerModule, MediaDurationPipe, @@ -239,15 +241,15 @@ export class MediaProgressBarComponent implements OnInit, OnDestroy { ], }) export class MediaControlsComponent { - public readonly animating = model(false); - public readonly duration = model(0); - public readonly progress = model(0); - public readonly playback_start = model(0); - public readonly playback_duration = model(0); - public readonly muted = model(false); - public readonly loading = model(false); - public readonly loop = model('NONE'); - public readonly state = model('PAUSED'); - public readonly shuffle = model(false); - public readonly event = output(); + public readonly animating = input(false); + public readonly duration = input(0); + public readonly progress = input(0); + public readonly playback_start = input(0); + public readonly playback_duration = input(0); + public readonly muted = input(false); + public readonly loading = input(false); + public readonly loop = input('NONE'); + public readonly state = input('PAUSED'); + public readonly shuffle = input(false); + public readonly event = output(); } diff --git a/apps/signage/src/app/media-helpers.ts b/apps/signage/src/app/media-helpers.ts index b445104d695..fedb1b94681 100644 --- a/apps/signage/src/app/media-helpers.ts +++ b/apps/signage/src/app/media-helpers.ts @@ -25,6 +25,18 @@ export function mockTimeState() { }; } +/** + * Whether an item can play now. Use this rather than `validateMedia` where only + * the answer is needed: it builds no message, so it is cheap in hot paths. + */ +export function isMediaValid(item: MediaPlayerItem, now = time()) { + if (!item?.id) return false; + if (item.valid_from && item.valid_from * 1000 > now) return false; + if (item.valid_until && item.valid_until * 1000 < now) return false; + return true; +} + +/** Why an item cannot play now, for display. Empty when it can play. */ export function validateMedia(item: MediaPlayerItem) { if (!item) return 'Invalid media: missing media data.'; if (!item.id) return 'Invalid media: missing media ID.'; @@ -69,10 +81,7 @@ export function findValidPlaylistIndex( let index = start_index; let checked = 0; while (checked < playlist.length) { - if ( - (include_start || checked > 0) && - validateMedia(playlist[index]) === '' - ) { + if ((include_start || checked > 0) && isMediaValid(playlist[index])) { return index; } index = (index + step + playlist.length) % playlist.length; diff --git a/apps/signage/src/app/media-player.component.ts b/apps/signage/src/app/media-player.component.ts index a13703b8cd8..fa09a30c9c0 100644 --- a/apps/signage/src/app/media-player.component.ts +++ b/apps/signage/src/app/media-player.component.ts @@ -5,6 +5,7 @@ import { linkedSignal, model, OnChanges, + OnDestroy, OnInit, output, signal, @@ -23,12 +24,15 @@ import { } from '@placeos/components'; import { MediaAnimation, SignagePlugin } from '@placeos/ts-client'; import { DebugOverlayComponent } from './debug-overlay.component'; -import { MediaControlsComponent } from './media-controls.component'; +import { + MediaControlEvent, + MediaControlsComponent, +} from './media-controls.component'; import { findValidPlaylistIndex, + isMediaValid, mockTimeState, time, - validateMedia, } from './media-helpers'; import { PlaylistDisplayComponent } from './playlist-display.component'; import { MediaEvent } from './signage.service'; @@ -99,6 +103,7 @@ const URL_RETRY_DELAY = 1000; > @@ -110,6 +115,7 @@ const URL_RETRY_DELAY = 1000; [play]="output_plugin_plays()[0]" (loaded)="onPluginLoad(0)" (statusChange)="onPluginStatus($event, 0)" + (finished)="onPluginFinished(0)" (plugin_interaction)="onPluginInteraction($event, 0)" (plugin_error)="onPluginError($event, 0)" /> @@ -144,6 +150,7 @@ const URL_RETRY_DELAY = 1000; > @@ -155,6 +162,7 @@ const URL_RETRY_DELAY = 1000; [play]="output_plugin_plays()[1]" (loaded)="onPluginLoad(1)" (statusChange)="onPluginStatus($event, 1)" + (finished)="onPluginFinished(1)" (plugin_interaction)="onPluginInteraction($event, 1)" (plugin_error)="onPluginError($event, 1)" /> @@ -275,7 +283,7 @@ const URL_RETRY_DELAY = 1000; }) export class MediaPlayerComponent extends AsyncHandler - implements OnInit, OnChanges + implements OnInit, OnChanges, OnDestroy { public readonly playlist = input([]); public readonly controls = input(false); @@ -307,16 +315,12 @@ export class MediaPlayerComponent public readonly progress = signal(0); public readonly progress_start = signal(0); public readonly progress_duration = signal(0); - public readonly hold_over_item = signal(true); public readonly in_animation = signal(false); public readonly defer_reveal = signal(false); public readonly waiting_for_item = signal(false); public readonly active_output = signal<0 | 1>(0); public readonly pending_output = signal<0 | 1>(0); - public readonly active_plugin = signal(null); - public readonly plugin_config = signal(null); - public readonly plugin_play = signal(0); public readonly output_plugins = signal<[SignagePlugin, SignagePlugin]>([ null, null, @@ -327,6 +331,8 @@ export class MediaPlayerComponent public readonly output_plugin_plays = signal<[number, number]>([0, 0]); private _plugin_finished = false; + /** Whether the lone item held on screen has been credited with a pass */ + private _held_pass_reported = false; private _deferred_reveal_item_id = ''; private _deferred_reveal_resume = true; private _deferred_reveal_transition = false; @@ -340,8 +346,17 @@ export class MediaPlayerComponent /** Increments on every item (re)display; scopes load-error handling so a * looping playlist keeps skipping a broken item rather than freezing on it */ private _display_generation = 0; - /** Media item ids whose URL is currently being fetched */ - private _url_fetch_in_flight = new Set(); + /** + * The URL request in flight per item id. A result is only kept if its + * request is still the current one, so a request for a source that has + * since been edited or removed cannot save a stale URL. + */ + private _url_requests = new Map(); + /** + * A plugin that failed while preloaded. It is not preloaded again, and + * loads afresh when its turn comes. + */ + private _failed_preload_id = ''; /** Id of the item we are currently waiting on a URL for, and when we began */ private _url_wait_item_id = ''; private _url_wait_started = 0; @@ -354,7 +369,12 @@ export class MediaPlayerComponent private _item_playlist: MediaPlayerItem[] = []; private _playlist_signature = ''; - private _item_urls: Record = {}; + /** + * Resolved URL per item id, in the form the browser reports it back. Null + * marks a resolution that failed and may be retried. + */ + private _item_urls: Record = {}; + private _destroyed = false; private _item_start = 0; private _item_progress = 0; private _item_real_start = 0; @@ -390,8 +410,6 @@ export class MediaPlayerComponent private readonly _web_element_1 = viewChild>('web_el_1'); - public readonly validateMedia = (i) => validateMedia(i); - private _container(output: 0 | 1 = this.active_output()) { return output === 0 ? this._container_0() : this._container_1(); } @@ -465,6 +483,12 @@ export class MediaPlayerComponent ); } + public override ngOnDestroy() { + this._destroyed = true; + for (const id of Object.keys(this._item_urls)) this._dropItemURL(id); + super.ngOnDestroy(); + } + /** * Check in with the watchdog whenever this player is showing what it * should be: an item it managed to load, or nothing because it is paused @@ -496,36 +520,15 @@ export class MediaPlayerComponent } public ngOnChanges(changes: SimpleChanges) { - if (changes.playlist) { - const next_playlist = this.playlist() || []; - const playlist_signature = - this._getPlaylistSignature(next_playlist); - if (playlist_signature !== this._playlist_signature) { - const was_playing = this.state() === 'PLAYING'; - const current_item = this.active_item; - this._playlist_signature = playlist_signature; - this._clearItemURLs(); - this.progress.set(0); - if (was_playing && next_playlist.length) this.togglePause(); - this._item_playlist = [...next_playlist]; - const current_index = this._item_playlist.findIndex( - (_) => _.id === current_item?.id, - ); - this.hold_over_item.set(false); - const target_index = current_index >= 0 ? current_index : 0; - this.setPlaylistItem(target_index, was_playing); - this._validatePlaylist(); - } - } + if (changes.playlist) this._applyPlaylist(this.playlist() || []); if (changes.animation_time) { document.documentElement.style.setProperty( '--transition-duration', `${this.animation_time() || 3000}ms`, ); } - if (changes.muted) { - this._video_element().nativeElement.muted = !!this.muted(); - } + // Keyed by the property name, not the `muted` alias + if (changes.mutedInput) this._applyMuted(); if (changes.override) { if (this.override()) { if (this.state() === 'PLAYING') this.togglePause(); @@ -552,7 +555,64 @@ export class MediaPlayerComponent this.muted.set(!this.muted()); const muted = this.muted(); this.mutedChange.emit(muted); - this._video_element().nativeElement.muted = muted; + this._applyMuted(); + } + + /** Apply the mute state to both outputs. Fast debug time stays muted. */ + private _applyMuted() { + const { active, speed } = mockTimeState(); + const fast = active && speed >= 4; + for (const output of [0, 1] as const) { + this._video_element(output).nativeElement.muted = + this.muted() || fast; + } + } + + /** + * Take on a new playlist. Items still in it unchanged keep their URL and + * output. If the item on screen is one of them it plays on uninterrupted, + * and the new playlist continues after it. + */ + private _applyPlaylist(next_playlist: MediaPlayerItem[]) { + const playlist_signature = this._getPlaylistSignature(next_playlist); + if (playlist_signature === this._playlist_signature) return; + this._playlist_signature = playlist_signature; + const old_signatures = new Map( + this._item_playlist.map((item) => [ + item.id, + this._itemSignature(item), + ]), + ); + const unchanged = new Set( + next_playlist + .filter( + (item) => + old_signatures.get(item.id) === + this._itemSignature(item), + ) + .map((item) => item.id), + ); + const current_item = this.active_item; + if (current_item && unchanged.has(current_item.id)) { + this._dropItemState(unchanged); + this._item_playlist = [...next_playlist]; + const index = this._item_playlist.findIndex( + (_) => _.id === current_item.id, + ); + this.index.set(index); + this.indexChange.emit(index); + } else { + const was_playing = this.state() === 'PLAYING'; + this._dropItemState(new Set()); + this.progress.set(0); + if (was_playing && next_playlist.length) this.togglePause(); + this._item_playlist = [...next_playlist]; + const current_index = this._item_playlist.findIndex( + (_) => _.id === current_item?.id, + ); + this.setPlaylistItem(Math.max(current_index, 0), was_playing); + } + this._validatePlaylist(); } public togglePause() { @@ -591,26 +651,12 @@ export class MediaPlayerComponent public nextItem() { if (this._shouldHoldSingleInteractiveItem(this.active_item)) return; - if (this.hold_over_item()) { - const item = this._item_playlist.shift(); - if (this.progress() > 50 && this.isValidMedia(item)) { - this.event.emit({ type: 'media_count', ref_id: item.id }); - } - this.setPlaylistItem(0); - this.hold_over_item.set(false); - return; - } - let next_index = this.index() + 1; + const index = this.index(); const loop = this.loop(); - if (loop === 'ONE') next_index = this.index(); - else if (loop === 'NONE' && next_index === this._item_playlist.length) { - // Playlist has ended; still credit the final item before pausing. - const last_index = this.index(); - const last_item = this._item_playlist[last_index]; - if (this.progress() > 50 && this.isValidMedia(last_item)) { - this.event.emit({ type: 'media_count', ref_id: last_item.id }); - } - this._emitPlaylistMetrics(last_index); + // Credit the item being left, and its playlist if it ends one + this._emitItemMetrics(index); + if (loop === 'NONE' && index + 1 === this._item_playlist.length) { + // Playlist has ended this.index.set(-1); this.state.set('PAUSED'); this._item_start = 0; @@ -620,16 +666,13 @@ export class MediaPlayerComponent this.progress_start.set(0); return; } - const new_index = this._normalisePlaylistIndex(next_index); - const old_item = this._item_playlist[this.index()]; - if (this.progress() > 50 && this.isValidMedia(old_item)) { - this.event.emit({ type: 'media_count', ref_id: old_item.id }); - } - this.setPlaylistItem(new_index); + this.setPlaylistItem( + this._normalisePlaylistIndex(loop === 'ONE' ? index : index + 1), + ); } public isValidMedia(item: MediaPlayerItem): boolean { - return validateMedia(item) === ''; + return isMediaValid(item); } /** @@ -666,10 +709,6 @@ export class MediaPlayerComponent public toggleShuffle() { this.shuffle.set(!this.shuffle()); const current_item = this.active_item; - if (this.hold_over_item()) { - this._item_playlist.shift(); - this.hold_over_item.set(false); - } if (this.shuffle()) { shuffleArrayWithFirstItem(this._item_playlist, this.index()); this.setPlaylistItem(0); @@ -685,7 +724,7 @@ export class MediaPlayerComponent } } - public handleControlEvent(event: any) { + public handleControlEvent(event: MediaControlEvent) { if (event === 'SHUFFLE') this.toggleShuffle(); else if (event === 'PLAY') this.togglePause(); else if (event === 'PAUSE') this.togglePause(); @@ -768,7 +807,13 @@ export class MediaPlayerComponent item.plugin?.playback_type === 'playsthrough' ) { if (this._plugin_finished) { - this.nextItem(); + // Finishing is a full play, however early it came + this.progress.set(100); + if (this._shouldHoldSingleInteractiveItem(item)) { + this._replayPlugin(item); + } else { + this.nextItem(); + } } else if (now > this._item_start + this._playThroughLimit(item)) { this._handleOverrunPlugin(item); } @@ -779,6 +824,7 @@ export class MediaPlayerComponent if (this._shouldHoldSingleInteractiveItem(item)) { this.progress.set(100); this.duration.set(Math.floor(playback_duration / 1000)); + this._reportHeldPass(); return; } // Reaching full duration means the current item displayed fine, so @@ -805,7 +851,6 @@ export class MediaPlayerComponent const item = this.active_item; const old_item = this._item_playlist[old_index]; - this._emitPlaylistMetrics(old_index); if (!item) return; if (!this.isValidMedia(item)) { if (old_index !== index) this.nextItem(); @@ -834,7 +879,6 @@ export class MediaPlayerComponent } else { const ready = this._showMediaItem( item, - index, output, resume_if_paused, should_transition, @@ -943,7 +987,18 @@ export class MediaPlayerComponent const output = this._item_output.get(item.id) ?? this.active_output(); const value = time(); this._setOutputPluginPlay(output, value); - this.plugin_play.set(value); + } + + /** + * Start a lone play-through plugin over once it finishes. Nothing else is + * waiting for the screen, so it is played again in place rather than left + * on its final frame. + */ + private _replayPlugin(item: MediaPlayerItem) { + this._emitItemMetrics(this.index()); + this._plugin_finished = false; + this._resetPlayback(); + this._playPreparedPlugin(item); } private _startDisplayAttempt(item: MediaPlayerItem, output: 0 | 1) { @@ -967,6 +1022,8 @@ export class MediaPlayerComponent this.progress.set(0); this.duration.set(0); this._plugin_finished = false; + this._held_pass_reported = false; + if (item.id === this._failed_preload_id) this._failed_preload_id = ''; this._last_video_speed.delete(output); } @@ -993,30 +1050,28 @@ export class MediaPlayerComponent private _showMediaItem( item: MediaPlayerItem, - index: number, output: 0 | 1, resume_if_paused: boolean, should_transition: boolean, ) { const url = this.url(item.id); if (!url) { - return this._handleMissingMediaURL(item, index, resume_if_paused); + return this._handleMissingMediaURL(item, resume_if_paused); } this._url_wait_item_id = ''; if (!this._shouldDeferReveal(item)) this.waiting_for_item.set(false); const active_el = this._activeMediaElement(item, output); - const url_string = url.toString(); const keep_webpage_loaded = item.type === 'webpage' && (this._shouldHoldSingleWebpage(item) || this._ready_output_items.has(this._outputKey(output, item))) && - active_el.src === url_string; + active_el.src === url; this._item_output.set(item.id, output); if (keep_webpage_loaded) { this._web_waiting_item_id = ''; this._finishDeferredReveal(item, 0); } else { - active_el.src = url_string; + active_el.src = url; } active_el.classList.remove('hidden'); if (item.type === 'webpage' && !keep_webpage_loaded) { @@ -1028,19 +1083,20 @@ export class MediaPlayerComponent private _handleMissingMediaURL( item: MediaPlayerItem, - index: number, resume_if_paused: boolean, ) { const fetched = this._item_urls[item.id] !== undefined; - const fetching = this._url_fetch_in_flight.has(item.id); + const fetching = this._url_requests.has(item.id); const still_loading = item.isLoading?.() ?? false; if ( this._shouldWaitForMediaURL(item, fetched, fetching, still_loading) ) { this._ensureItemURL(item); this.waiting_for_item.set(true); + // Retry whatever is current then, as a playlist change may have + // moved this item to another index this.timeout('wait-for-url', () => - this.setPlaylistItem(index, resume_if_paused), + this.setPlaylistItem(this.index(), resume_if_paused), ); return false; } @@ -1099,7 +1155,7 @@ export class MediaPlayerComponent log( 'MediaPlayer', `Webpage "${item.name}" did not load in time; continuing.`, - [this.url(item.id)?.toString()], + [this.url(item.id)], 'warn', ); // Shown as far as this player can tell; a page the browser @@ -1173,12 +1229,20 @@ export class MediaPlayerComponent if (status !== 'unknown') { this._responded_output_items.add(this._outputKey(output, item)); } - if (status === 'ready') { - this._handlePluginReady(item, output); - } else if (status === 'finished' && this.active_item?.id === item.id) { - // A preloaded plugin finishing must not end the one on screen - this._plugin_finished = true; - } + if (status === 'ready') this._handlePluginReady(item, output); + } + + /** + * Called for every `finished` message, not only on a status change, so a + * replayed plugin can finish again. + */ + public onPluginFinished(output: 0 | 1 = this._activeItemOutput()) { + const item = this.active_item; + // A preloaded plugin finishing must not end the one on screen + if (item?.type !== 'plugin') return; + if (this._item_output.get(item.id) !== output) return; + log('MediaPlayer', 'Plugin finished', [item.name]); + this._plugin_finished = true; } public onPluginLoad(output: 0 | 1 = this.pending_output()) { @@ -1206,19 +1270,23 @@ export class MediaPlayerComponent error: PluginErrorPayload, output: 0 | 1 = this._activeItemOutput(), ) { + log('MediaPlayer', `Plugin error: ${error?.message}`, [error], 'error'); + if (!error?.fatal) return; const item = this.active_item; if ( item?.type === 'plugin' && - this._item_output.get(item.id) !== output - ) - return; - log('MediaPlayer', `Plugin error: ${error.message}`, [error], 'error'); - if (!error.fatal) return; - if (item?.type === 'plugin') { + this._item_output.get(item.id) === output + ) { this._failPluginItem(item, output); - } else { - this.nextItem(); + return; } + // A plugin preloaded for later. Remove it without touching the item + // on screen, so it loads afresh when its turn comes. + const preloaded = this._output_items[output]; + if (preloaded?.type !== 'plugin') return; + if (output === this.active_output()) return; + this._failed_preload_id = preloaded.id; + this._clearOutput(output); } /** @@ -1252,7 +1320,9 @@ export class MediaPlayerComponent } // Already removed from screen and waiting for its retry const output = this._item_output.get(item.id); - if (output === undefined || this._pluginResponded(item, output)) { + if (output === undefined) return; + if (this._pluginResponded(item, output)) { + this._reportHeldPass(); return; } log( @@ -1268,8 +1338,10 @@ export class MediaPlayerComponent log('MediaPlayer', `Showing plugin: ${item.name}`, [item.plugin?.name]); this._item_output.set(item.id, output); this._setOutputPlugin(output, item.plugin); - this.active_plugin.set(item.plugin); - this._waitForPluginLoad(item, output); + // A plugin preloaded on this output may already have reported ready + if (!this._ready_output_items.has(this._outputKey(output, item))) { + this._waitForPluginLoad(item, output); + } } private _waitForPluginLoad(item: MediaPlayerItem, output: 0 | 1) { @@ -1315,7 +1387,6 @@ export class MediaPlayerComponent this._ready_output_items.add(this._outputKey(output, item)); if (this.active_item?.id !== item.id) return; this._markShown(item); - this.plugin_config.set(config); if ( this._deferred_reveal_item_id === item.id && this.pending_output() === output @@ -1417,15 +1488,19 @@ export class MediaPlayerComponent log( 'MediaPlayer', `Failed to load ${item.type} media "${item.name}"`, - [this.url(item.id)?.toString()], + [this.url(item.id)], 'warn', ); this._markNotShown(item); this._skipFailedMedia(this._item_start); } + /** + * Identifies the current display attempt. Keyed by item rather than index, + * as a playlist change can move the item on screen to another index. + */ private _currentMediaCycle() { - return `${this.index()}:${this._display_generation}`; + return `${this.active_item?.id || ''}:${this._display_generation}`; } private _skipFailedMedia(wait_started = time()) { @@ -1435,11 +1510,7 @@ export class MediaPlayerComponent this._consecutive_load_errors++; // Drop the cached URL so a transient failure can be re-fetched on retry. const failed = this.active_item; - if (failed) { - const url = this._item_urls[failed.id]; - if (url) URL.revokeObjectURL(url.toString()); - delete this._item_urls[failed.id]; - } + if (failed) this._dropItemURL(failed.id); // If every playable item has failed to load, stop cycling (which would // peg the CPU) and retry the whole playlist after a short delay. if (valid_count <= 1 || this._consecutive_load_errors >= valid_count) { @@ -1481,11 +1552,8 @@ export class MediaPlayerComponent this._ensureItemURL(item); } // Revoke old URLs - for (const key in this._item_urls) { - if (item_list.find((_) => _?.id === key)) continue; - const url = this._item_urls[key]; - if (url) URL.revokeObjectURL(url.toString()); - delete this._item_urls[key]; + for (const id of Object.keys(this._item_urls)) { + if (!item_list.some((_) => _?.id === id)) this._dropItemURL(id); } this._preloadUpcomingInteractiveContent(current_index); } @@ -1524,6 +1592,7 @@ export class MediaPlayerComponent return; } if (this._output_items[output]?.id === item.id) return; + if (item.id === this._failed_preload_id) return; this._clearOutput(output); this._output_items[output] = item; this._item_output.set(item.id, output); @@ -1532,7 +1601,7 @@ export class MediaPlayerComponent const url = this.url(item.id); if (!url) return; const web_el = this._web_element(output).nativeElement; - web_el.src = url.toString(); + web_el.src = url; web_el.classList.remove('hidden'); return; } @@ -1573,19 +1642,23 @@ export class MediaPlayerComponent // A truthy entry is already a usable URL; '' / null mark a previous // failure that we retry, undefined means we have not fetched it yet. if (this._item_urls[item.id]) return; - if (this._url_fetch_in_flight.has(item.id)) return; + if (this._url_requests.has(item.id)) return; // Failures are retried, but not on every 50ms tick if ((this._url_retry_after.get(item.id) || 0) > Date.now()) return; const id = item.id; - this._url_fetch_in_flight.add(id); - let settled = false; + const request = Symbol(id); + this._url_requests.set(id, request); const settle = (resolved: string | URL | null) => { - if (settled) return; - settled = true; - this.clearTimeout(`url-fetch-${id}`); - this._url_fetch_in_flight.delete(id); - this._item_urls[id] = (resolved ?? null) as any; - if (!resolved) { + const url = this._normaliseURL(resolved); + // Timed out, dropped by a playlist edit, or the player is gone. + // Release it so its file is freed. + if (this._url_requests.get(id) !== request || this._destroyed) { + if (url) URL.revokeObjectURL(url); + return; + } + this._cancelURLRequest(id); + this._item_urls[id] = url; + if (!url) { this._url_retry_after.set(id, Date.now() + URL_RETRY_DELAY); } else { this._url_retry_after.delete(id); @@ -1599,6 +1672,32 @@ export class MediaPlayerComponent this.timeout(`url-fetch-${id}`, () => settle(null), URL_FETCH_TIMEOUT); } + /** + * The URL in the form an element reports back from `src`, so the two can + * be compared. Null when there is no URL. + */ + private _normaliseURL(url: string | URL | null) { + if (!url) return null; + try { + return new URL(url, document.baseURI).href; + } catch { + return `${url}`; + } + } + + /** Stop waiting on an item's URL request. A late result is released. */ + private _cancelURLRequest(id: string) { + this.clearTimeout(`url-fetch-${id}`); + this._url_requests.delete(id); + } + + /** Forget the URL resolved for an item, releasing it if it is a blob */ + private _dropItemURL(id: string) { + const url = this._item_urls[id]; + if (url) URL.revokeObjectURL(url); + delete this._item_urls[id]; + } + private _transition(resume_on_end = true) { if (!this.active_item) return; if (this.state() === 'PLAYING') this.togglePause(); @@ -1737,6 +1836,26 @@ export class MediaPlayerComponent return this._item_playlist.some((item) => this.isValidMedia(item)); } + /** + * Credit a lone item held on screen with one pass, once, when it has run + * its time. Without this a single-pass override that holds its only item + * would never report `playlist_through`, and so would never end. + */ + private _reportHeldPass() { + if (this._held_pass_reported) return; + this._held_pass_reported = true; + this._emitItemMetrics(this.index()); + } + + /** Credit the item at `idx` as it is left, and its playlist if it ends one */ + private _emitItemMetrics(idx: number) { + const item = this._item_playlist[idx]; + if (this.progress() > 50 && this.isValidMedia(item)) { + this.event.emit({ type: 'media_count', ref_id: item.id }); + } + this._emitPlaylistMetrics(idx); + } + private _emitPlaylistMetrics(idx: number) { const item = this._item_playlist[idx]; if (!this._isLastValidPlaylistItem(idx) || !item?.playlist) return; @@ -1764,23 +1883,26 @@ export class MediaPlayerComponent } private _getPlaylistSignature(playlist: MediaPlayerItem[]) { - return JSON.stringify( - playlist.map((item) => ({ - id: item.id, - name: item.name, - playlist: item.playlist, - playlist_name: item.playlist_name, - type: item.type, - url: item.url, - animation: item.animation, - start_time: item.start_time, - duration: item.duration, - valid_from: item.valid_from, - valid_until: item.valid_until, - plugin_id: item.plugin?.id, - plugin_params: item.plugin_params, - })), - ); + return playlist.map((item) => this._itemSignature(item)).join('\n'); + } + + /** Everything about an item that changes what or how it plays */ + private _itemSignature(item: MediaPlayerItem) { + return JSON.stringify({ + id: item.id, + name: item.name, + playlist: item.playlist, + playlist_name: item.playlist_name, + type: item.type, + url: item.url, + animation: item.animation, + start_time: item.start_time, + duration: item.duration, + valid_from: item.valid_from, + valid_until: item.valid_until, + plugin_id: item.plugin?.id, + plugin_params: item.plugin_params, + }); } private _normalisePlaylistIndex(index: number) { @@ -1788,19 +1910,27 @@ export class MediaPlayerComponent return length ? (index + length) % length : -1; } - private _clearItemURLs() { - for (const key in this._item_urls) { - const url = this._item_urls[key]; - if (url) URL.revokeObjectURL(url.toString()); - delete this._item_urls[key]; + /** Forget the URL and prepared output of every item not in `keep` */ + private _dropItemState(keep: Set) { + for (const id of Object.keys(this._item_urls)) { + if (!keep.has(id)) this._dropItemURL(id); + } + for (const id of [...this._url_requests.keys()]) { + if (!keep.has(id)) this._cancelURLRequest(id); + } + for (const id of [...this._url_retry_after.keys()]) { + if (!keep.has(id)) this._url_retry_after.delete(id); + } + if (!keep.has(this._shown_item_id)) this._shown_item_id = ''; + if (!keep.has(this._failed_preload_id)) this._failed_preload_id = ''; + for (const output of [0, 1] as const) { + const item = this._output_items[output]; + if (!item || keep.has(item.id)) continue; + this._item_output.delete(item.id); + this._ready_output_items.delete(this._outputKey(output, item)); + this._responded_output_items.delete(this._outputKey(output, item)); + this._output_items[output] = null; + this._setOutputPlugin(output, null); } - this._url_retry_after.clear(); - this._shown_item_id = ''; - this._item_output.clear(); - this._output_items = [null, null]; - this._ready_output_items.clear(); - this._responded_output_items.clear(); - this._setOutputPlugin(0, null); - this._setOutputPlugin(1, null); } } diff --git a/apps/signage/src/tests/media-controls.component.spec.ts b/apps/signage/src/tests/media-controls.component.spec.ts index 27beb7246f3..e767328f795 100644 --- a/apps/signage/src/tests/media-controls.component.spec.ts +++ b/apps/signage/src/tests/media-controls.component.spec.ts @@ -18,15 +18,24 @@ describe('MediaControlsComponent', () => { spectator = create_component(); }); + it('should show a ripple on each control', () => { + const buttons = spectator.queryAll('button'); + + expect(buttons.length).toBeGreaterThan(0); + for (const button of buttons) { + expect(button.classList).toContain('mat-ripple'); + } + }); + it('should show a spinner over the play/pause control while loading', () => { - spectator.component.loading.set(true); + spectator.setInput('loading', true); spectator.detectChanges(); expect(spectator.query('mat-spinner')).toBeTruthy(); }); it('should hide the spinner when playback is not loading', () => { - spectator.component.loading.set(false); + spectator.setInput('loading', false); spectator.detectChanges(); expect(spectator.query('mat-spinner')).toBeFalsy(); @@ -79,7 +88,7 @@ describe('MediaControlsComponent', () => { it('should emit PAUSE from the play/pause control while playing', () => { const emit_spy = vi.spyOn(spectator.component.event, 'emit'); - spectator.component.state.set('PLAYING'); + spectator.setInput('state', 'PLAYING'); spectator.detectChanges(); const buttons = spectator.queryAll('button') as HTMLButtonElement[]; @@ -90,7 +99,7 @@ describe('MediaControlsComponent', () => { it('should emit PLAY from the play/pause control while paused', () => { const emit_spy = vi.spyOn(spectator.component.event, 'emit'); - spectator.component.state.set('PAUSED'); + spectator.setInput('state', 'PAUSED'); spectator.detectChanges(); const buttons = spectator.queryAll('button') as HTMLButtonElement[]; @@ -102,7 +111,7 @@ describe('MediaControlsComponent', () => { it('should show the green animating overlay while animating', () => { expect(spectator.query('.bg-success')).toBeFalsy(); - spectator.component.animating.set(true); + spectator.setInput('animating', true); spectator.detectChanges(); expect(spectator.query('.bg-success')).toBeTruthy(); @@ -130,58 +139,58 @@ describe('MediaControlsComponent (icons)', () => { }; it('should show the pause icon while playing', () => { - spectator.component.state.set('PLAYING'); + spectator.setInput('state', 'PLAYING'); spectator.detectChanges(); expect(icon_text(1)).toBe('pause'); }); it('should show the play icon while paused', () => { - spectator.component.state.set('PAUSED'); + spectator.setInput('state', 'PAUSED'); spectator.detectChanges(); expect(icon_text(1)).toBe('play_arrow'); }); it('should toggle the volume icon when muted', () => { - spectator.component.muted.set(false); + spectator.setInput('muted', false); spectator.detectChanges(); expect(icon_text(3)).toBe('volume_up'); - spectator.component.muted.set(true); + spectator.setInput('muted', true); spectator.detectChanges(); expect(icon_text(3)).toBe('volume_off'); }); it('should show the repeat_one icon when looping a single item', () => { - spectator.component.loop.set('ONE'); + spectator.setInput('loop', 'ONE'); spectator.detectChanges(); expect(icon_text(4)).toBe('repeat_one'); }); it('should dim the loop icon when looping is off', () => { - spectator.component.loop.set('NONE'); + spectator.setInput('loop', 'NONE'); spectator.detectChanges(); const loop_icon = ( spectator.queryAll('button')[4] as HTMLButtonElement ).querySelector('icon'); expect(loop_icon?.classList).toContain('opacity-30'); - spectator.component.loop.set('ALL'); + spectator.setInput('loop', 'ALL'); spectator.detectChanges(); expect(loop_icon?.classList).not.toContain('opacity-30'); }); it('should dim the shuffle icon when shuffle is off', () => { - spectator.component.shuffle.set(false); + spectator.setInput('shuffle', false); spectator.detectChanges(); const shuffle_icon = ( spectator.queryAll('button')[5] as HTMLButtonElement ).querySelector('icon'); expect(shuffle_icon?.classList).toContain('opacity-30'); - spectator.component.shuffle.set(true); + spectator.setInput('shuffle', true); spectator.detectChanges(); expect(shuffle_icon?.classList).not.toContain('opacity-30'); }); diff --git a/apps/signage/src/tests/media-helpers.spec.ts b/apps/signage/src/tests/media-helpers.spec.ts index d3294cbaa39..f77662096ad 100644 --- a/apps/signage/src/tests/media-helpers.spec.ts +++ b/apps/signage/src/tests/media-helpers.spec.ts @@ -1,6 +1,7 @@ import { MediaAnimation } from '@placeos/ts-client'; import { findValidPlaylistIndex, + isMediaValid, mockTimeState, setMockTime, time, @@ -97,6 +98,25 @@ describe('time helpers', () => { }); }); +describe('isMediaValid', () => { + it('agrees with validateMedia', () => { + const now = Date.now(); + const expired = create_item('expired', true); + expired.valid_until = Math.floor((now - 60 * 1000) / 1000); + const items = [ + create_item('valid', true), + create_item('future', false), + expired, + create_item('', true), + null, + ]; + + for (const item of items) { + expect(isMediaValid(item)).toBe(validateMedia(item) === ''); + } + }); +}); + describe('validateMedia', () => { afterEach(() => setMockTime(0)); diff --git a/apps/signage/src/tests/media-player.component.spec.ts b/apps/signage/src/tests/media-player.component.spec.ts index 1a9886226c9..265371189e4 100644 --- a/apps/signage/src/tests/media-player.component.spec.ts +++ b/apps/signage/src/tests/media-player.component.spec.ts @@ -1,4 +1,8 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + createComponentFactory, + createHostFactory, + Spectator, +} from '@ngneat/spectator/vitest'; import { MediaAnimation, SignagePlugin } from '@placeos/ts-client'; import { setMockTime } from '../app/media-helpers'; @@ -118,7 +122,6 @@ describe('MediaPlayerComponent', () => { it('should pause playback when loop mode is NONE and the playlist ends', () => { load_playlist([create_item('media-1')]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); spectator.component.loop.set('NONE'); spectator.component.state.set('PLAYING'); @@ -131,7 +134,6 @@ describe('MediaPlayerComponent', () => { it('should not transition when looping the current item to itself', () => { load_playlist([create_item('media-1'), create_item('media-2')]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); spectator.component.loop.set('ONE'); const transition_spy = vi.spyOn( spectator.component as any, @@ -184,13 +186,11 @@ describe('MediaPlayerComponent', () => { load_playlist(items); spectator.component.index.set(1); spectator.component.progress.set(60); - spectator.component.hold_over_item.set(false); load_playlist(items.map((item) => ({ ...item }))); expect(spectator.component.index()).toBe(1); expect(spectator.component.progress()).toBe(60); - expect(spectator.component.hold_over_item()).toBe(false); expect(spectator.component.playlist_items.map((_) => _.id)).toEqual([ 'media-1', 'media-2', @@ -258,7 +258,7 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(0); spectator.component.progress.set(60); spectator.component['_item_urls'] = { - 'media-1': 'blob:old-url' as any, + 'media-1': 'blob:old-url', }; load_playlist([create_item('media-1', { url: 'new-url' })]); @@ -274,7 +274,6 @@ describe('MediaPlayerComponent', () => { create_item('media-3'), ]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); await spectator.component.previousItem(); @@ -293,7 +292,7 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(0); spectator.component.progress.set(75); - await spectator.component.setPlaylistItem(1); + spectator.component.nextItem(); expect(event_spy).toHaveBeenCalledWith({ type: 'playlist_count', @@ -313,7 +312,6 @@ describe('MediaPlayerComponent', () => { load_playlist([item]); spectator.component.index.set(0); spectator.component.state.set('PLAYING'); - spectator.component.hold_over_item.set(false); spectator.component['_item_start'] = 1_000; const next_item_spy = vi.spyOn(spectator.component, 'nextItem'); @@ -329,7 +327,6 @@ describe('MediaPlayerComponent', () => { load_playlist([item]); spectator.component.index.set(0); spectator.component.state.set('PLAYING'); - spectator.component.hold_over_item.set(false); spectator.component['_item_start'] = 1_000; spectator.component['_updateItem'](); @@ -349,7 +346,6 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(0); spectator.component.state.set('PLAYING'); spectator.component.muted.set(false); - spectator.component.hold_over_item.set(false); spectator.component['_item_start'] = 1_000; spectator.component['_updateItem'](); @@ -366,7 +362,6 @@ describe('MediaPlayerComponent', () => { load_playlist([item]); spectator.component.index.set(0); spectator.component.state.set('PLAYING'); - spectator.component.hold_over_item.set(false); spectator.component['_item_start'] = 1_000; spectator.component['_updateItem'](); @@ -407,7 +402,7 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(0); spectator.component.progress.set(75); - await spectator.component.setPlaylistItem(1); + spectator.component.nextItem(); expect(event_spy).toHaveBeenCalledWith({ type: 'playlist_count', @@ -429,7 +424,7 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(1); spectator.component.progress.set(75); - await spectator.component.setPlaylistItem(0); + spectator.component.nextItem(); expect(event_spy).toHaveBeenCalledWith({ type: 'playlist_count', @@ -447,7 +442,7 @@ describe('MediaPlayerComponent', () => { spectator.component.index.set(0); spectator.component.progress.set(75); - await spectator.component.setPlaylistItem(0); + spectator.component.nextItem(); expect(event_spy).toHaveBeenCalledWith({ type: 'playlist_count', @@ -467,7 +462,6 @@ describe('MediaPlayerComponent', () => { ]; load_playlist(items); spectator.component.index.set(1); - spectator.component.hold_over_item.set(false); spectator.component.loop.set('NONE'); spectator.component.state.set('PLAYING'); spectator.component.progress.set(75); @@ -498,7 +492,6 @@ describe('MediaPlayerComponent', () => { }), ]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); await spectator.component.setPlaylistItem(1); @@ -513,7 +506,6 @@ describe('MediaPlayerComponent', () => { }), ]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); const transition_spy = vi.spyOn( spectator.component as any, '_transition', @@ -532,7 +524,6 @@ describe('MediaPlayerComponent', () => { }); load_playlist([item]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); const set_item_spy = vi.spyOn(spectator.component, 'setPlaylistItem'); const transition_spy = vi.spyOn( spectator.component as any, @@ -558,7 +549,6 @@ describe('MediaPlayerComponent', () => { }); load_playlist([item]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); const set_item_spy = vi.spyOn(spectator.component, 'setPlaylistItem'); const transition_spy = vi.spyOn( spectator.component as any, @@ -658,11 +648,10 @@ describe('MediaPlayerComponent', () => { ]; load_playlist(items); spectator.component['_item_urls'] = { - 'media-1': 'blob:media-1' as any, - 'webpage-1': 'blob:webpage-1' as any, + 'media-1': 'blob:media-1', + 'webpage-1': 'blob:webpage-1', }; spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); spectator.component.state.set('PLAYING'); spectator.component['clearTimeout']('wait-for-url'); const transition_spy = vi.spyOn( @@ -706,8 +695,8 @@ describe('MediaPlayerComponent', () => { spectator.component['_item_start'] = Date.now() - 4_000; spectator.component['_item_real_start'] = Date.now() - 4_000; spectator.component['_item_urls'] = { - 'webpage-1': 'blob:webpage-1' as any, - 'webpage-2': 'blob:webpage-2' as any, + 'webpage-1': 'blob:webpage-1', + 'webpage-2': 'blob:webpage-2', }; spectator.component['_processURLs'](); @@ -739,8 +728,8 @@ describe('MediaPlayerComponent', () => { spectator.component['_item_start'] = -2_000; spectator.component['_item_real_start'] = 250; spectator.component['_item_urls'] = { - 'webpage-1': 'blob:webpage-1' as any, - 'webpage-2': 'blob:webpage-2' as any, + 'webpage-1': 'blob:webpage-1', + 'webpage-2': 'blob:webpage-2', }; spectator.component['_processURLs'](); @@ -771,8 +760,8 @@ describe('MediaPlayerComponent', () => { spectator.component['_item_start'] = Date.now() - 6_000; spectator.component['_item_real_start'] = Date.now() - 6_000; spectator.component['_item_urls'] = { - 'webpage-1': 'blob:webpage-1' as any, - 'webpage-2': 'blob:webpage-2' as any, + 'webpage-1': 'blob:webpage-1', + 'webpage-2': 'blob:webpage-2', }; spectator.component['_processURLs'](); @@ -792,12 +781,11 @@ describe('MediaPlayerComponent', () => { ]; load_playlist(items); spectator.component['_item_urls'] = { - 'media-1': 'blob:media-1' as any, - 'webpage-1': 'blob:webpage-1' as any, - 'webpage-2': 'blob:webpage-2' as any, + 'media-1': 'blob:media-1', + 'webpage-1': 'blob:webpage-1', + 'webpage-2': 'blob:webpage-2', }; spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); spectator.component.state.set('PLAYING'); spectator.component['clearTimeout']('wait-for-url'); @@ -839,8 +827,8 @@ describe('MediaPlayerComponent', () => { spectator.component['_item_start'] = Date.now() - 6_000; spectator.component['_item_real_start'] = Date.now() - 6_000; spectator.component['_item_urls'] = { - 'webpage-1': 'blob:webpage-1' as any, - 'webpage-2': 'blob:webpage-2' as any, + 'webpage-1': 'blob:webpage-1', + 'webpage-2': 'blob:webpage-2', }; spectator.component['_processURLs'](); @@ -1012,7 +1000,6 @@ describe('MediaPlayerComponent', () => { it('should pause cleanly if replaying a looping video is blocked', async () => { load_playlist([create_item('video-1', { type: 'video' })]); spectator.component.index.set(0); - spectator.component.hold_over_item.set(false); spectator.component.loop.set('ONE'); const next_item_spy = vi.spyOn(spectator.component, 'nextItem'); Object.defineProperty(HTMLMediaElement.prototype, 'play', { @@ -1038,22 +1025,21 @@ describe('MediaPlayerComponent', () => { }); load_playlist([plugin_item]); spectator.component.index.set(0); - spectator.component.active_plugin.set(plugin_item.plugin); spectator.component.onPluginStatus('ready'); - expect(spectator.component.plugin_config()).toEqual({ + expect(spectator.component.output_plugin_configs()[0]).toEqual({ instance_id: 'plugin-1', config: { theme: 'dark' }, timing: { scheduled_duration_ms: 20000 }, }); expect(spectator.component.defer_reveal()).toBe(true); - expect(spectator.component.plugin_play()).toBe(0); + expect(spectator.component.output_plugin_plays()[0]).toBe(0); vi.advanceTimersByTime(2000); vi.advanceTimersToNextFrame(); expect(spectator.component.defer_reveal()).toBe(false); - expect(spectator.component.plugin_play()).toBeGreaterThan(0); + expect(spectator.component.output_plugin_plays()[0]).toBeGreaterThan(0); vi.useRealTimers(); }); @@ -1072,7 +1058,7 @@ describe('MediaPlayerComponent', () => { spectator.component.onPluginLoad(); - expect(spectator.component.plugin_config()).toEqual({ + expect(spectator.component.output_plugin_configs()[0]).toEqual({ instance_id: 'plugin-1', config: { theme: 'dark' }, timing: { scheduled_duration_ms: 20000 }, @@ -1081,7 +1067,7 @@ describe('MediaPlayerComponent', () => { vi.advanceTimersByTime(2000); vi.advanceTimersToNextFrame(); expect(spectator.component.defer_reveal()).toBe(false); - expect(spectator.component.plugin_play()).toBeGreaterThan(0); + expect(spectator.component.output_plugin_plays()[0]).toBeGreaterThan(0); vi.useRealTimers(); }); @@ -1104,7 +1090,7 @@ describe('MediaPlayerComponent', () => { vi.advanceTimersByTime(15_000); - expect(spectator.component.plugin_config()).toEqual({ + expect(spectator.component.output_plugin_configs()[0]).toEqual({ instance_id: 'plugin-1', config: { theme: 'dark' }, timing: { scheduled_duration_ms: 20000 }, @@ -1113,7 +1099,7 @@ describe('MediaPlayerComponent', () => { vi.advanceTimersByTime(2000); vi.advanceTimersToNextFrame(); expect(spectator.component.defer_reveal()).toBe(false); - expect(spectator.component.plugin_play()).toBeGreaterThan(0); + expect(spectator.component.output_plugin_plays()[0]).toBeGreaterThan(0); vi.useRealTimers(); }); @@ -1239,7 +1225,7 @@ describe('MediaPlayerComponent', () => { spectator.component['_updateItem'](); expect(next_item_spy).not.toHaveBeenCalled(); - spectator.component.onPluginStatus('finished', output); + spectator.component.onPluginFinished(output); spectator.component['_updateItem'](); expect(next_item_spy).toHaveBeenCalled(); }); @@ -1356,8 +1342,8 @@ describe('MediaPlayerComponent', () => { const image_item = create_item('image-1'); load_playlist([web_item, image_item]); spectator.component['_item_urls'] = { - 'webpage-1': 'https://example.com/page' as any, - 'image-1': 'blob:image-1' as any, + 'webpage-1': 'https://example.com/page', + 'image-1': 'blob:image-1', }; spectator.component.setPlaylistItem(0); @@ -1427,7 +1413,7 @@ describe('MediaPlayerComponent', () => { }, ); spectator.component['_item_urls'] = { - 'bad-1': 'blob:bad-1' as any, + 'bad-1': 'blob:bad-1', }; // Land on the broken item and fail during its first second. @@ -1448,7 +1434,7 @@ describe('MediaPlayerComponent', () => { next_item_spy.mockClear(); skip_callback = () => undefined; spectator.component['_item_urls'] = { - 'bad-1': 'blob:bad-1' as any, + 'bad-1': 'blob:bad-1', }; spectator.component.setPlaylistItem(1); spectator.component['_item_start'] = 10_000; @@ -1470,7 +1456,7 @@ describe('MediaPlayerComponent', () => { .spyOn(spectator.component as any, 'timeout') .mockImplementation(() => undefined); spectator.component['_item_urls'] = { - 'bad-1': 'blob:bad-1' as any, + 'bad-1': 'blob:bad-1', }; spectator.component.setPlaylistItem(1); @@ -1500,7 +1486,7 @@ describe('MediaPlayerComponent', () => { .mockImplementation(() => undefined); // The fetch for "a" has been in-flight far longer than the wait cap. - spectator.component['_url_fetch_in_flight'].add('a'); + spectator.component['_url_requests'].set('a', Symbol('a')); spectator.component['_url_wait_item_id'] = 'a'; spectator.component['_url_wait_started'] = 0; next_item_spy.mockClear(); @@ -1627,7 +1613,7 @@ describe('MediaPlayerComponent', () => { const show_item = (id: string) => { load_playlist([create_item(id)]); spectator.component['_item_urls'] = { - [id]: `blob:${id}` as any, + [id]: `blob:${id}`, }; spectator.component.setPlaylistItem(0); spectator.component.state.set('PLAYING'); @@ -1675,7 +1661,7 @@ describe('MediaPlayerComponent', () => { create_item('a', { getURL: async () => '' }), create_item('b'), ]); - spectator.component['_item_urls'] = { a: 'blob:a' as any }; + spectator.component['_item_urls'] = { a: 'blob:a' }; spectator.component.setPlaylistItem(0); spectator.component.onMediaLoadSuccess(); expect(checks_in()).toBe(true); @@ -1685,7 +1671,7 @@ describe('MediaPlayerComponent', () => { ); // The URL is gone and the wait for a new one has run out - spectator.component['_item_urls'] = { a: null as any }; + spectator.component['_item_urls'] = { a: null }; spectator.component['_url_wait_item_id'] = 'a'; spectator.component['_url_wait_started'] = 0; spectator.component.setPlaylistItem(0); @@ -1714,4 +1700,431 @@ describe('MediaPlayerComponent', () => { expect(early_calls).toBeLessThanOrEqual(2); expect(get_url.mock.calls.length).toBeLessThanOrEqual(early_calls + 6); }); + + describe('object URL cleanup', () => { + it('should revoke cached object URLs when destroyed', () => { + load_playlist([create_item('a')]); + spectator.component['_item_urls'] = { a: 'blob:a' }; + + spectator.fixture.destroy(); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:a'); + }); + + it('should revoke an object URL that resolves after the fetch gave up', async () => { + vi.useFakeTimers(); + let resolve_url: (url: string) => void = () => undefined; + load_playlist([ + create_item('a', { + getURL: () => + new Promise( + (resolve) => (resolve_url = resolve), + ), + }), + ]); + await vi.advanceTimersByTimeAsync(30_000); + + resolve_url('blob:late'); + await vi.advanceTimersByTimeAsync(0); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:late'); + expect(spectator.component.url('a')).toBe(''); + }); + + it('should store resolved URLs in the form the browser reports them', async () => { + vi.useFakeTimers(); + load_playlist([ + create_item('web', { + type: 'webpage', + getURL: async () => 'https://example.com', + }), + ]); + await vi.advanceTimersByTimeAsync(100); + + expect(spectator.component.url('web')).toBe('https://example.com/'); + }); + }); + + it('should keep a preloaded webpage loaded when its URL is not in canonical form', async () => { + vi.useFakeTimers(); + const web_item = create_item('web', { + type: 'webpage', + getURL: async () => 'https://example.com', + }); + load_playlist([create_item('image-1'), web_item]); + spectator.component['_ensureItemURL'](web_item); + await vi.advanceTimersByTimeAsync(0); + // The webpage has been preloaded and has loaded on the inactive output + const web_el = spectator.component['_web_element'](1).nativeElement; + web_el.src = 'https://example.com'; + spectator.component['_output_items'][1] = web_item; + spectator.component['_item_output'].set('web', 1); + spectator.component['_ready_output_items'].add( + spectator.component['_outputKey'](1, web_item), + ); + + spectator.component.setPlaylistItem(1); + + expect(spectator.component['_web_waiting_item_id']).toBe(''); + }); + + describe('playlist changes', () => { + it('should keep playing the current item when the playlist changes around it', async () => { + vi.useFakeTimers(); + load_playlist([create_item('a'), create_item('b')]); + await vi.advanceTimersByTimeAsync(500); + const image_el = + spectator.component['_image_element'](0).nativeElement; + expect(image_el.classList).not.toContain('hidden'); + const item_start = spectator.component['_item_start']; + expect(item_start).toBeGreaterThan(0); + + load_playlist([ + create_item('c'), + create_item('a'), + create_item('b'), + ]); + + expect(spectator.component.index()).toBe(1); + expect(spectator.component.active_item?.id).toBe('a'); + expect(spectator.component['_item_start']).toBe(item_start); + expect(spectator.component.url('a')).toBe('blob:a'); + expect(image_el.classList).not.toContain('hidden'); + expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:a'); + }); + }); + + it('should not credit a playlist while waiting for an item URL', async () => { + vi.useFakeTimers(); + const event_spy = vi.spyOn(spectator.component.event, 'emit'); + load_playlist([ + create_item('a', { + getURL: () => new Promise(() => undefined), + }), + ]); + + await vi.advanceTimersByTimeAsync(2_500); + + expect(event_spy).not.toHaveBeenCalledWith({ + type: 'playlist_count', + ref_id: 'playlist-1', + }); + }); + + it('should ignore a fatal error from a preloaded plugin while another item plays', () => { + const plugin_item = create_item('plugin-1', { + type: 'plugin', + plugin: { + id: 'plugin-1', + name: 'Weather', + uri: 'https://plugins.example/weather', + } as SignagePlugin, + }); + load_playlist([create_item('image-1'), plugin_item]); + spectator.component['_output_items'][1] = plugin_item; + spectator.component['_item_output'].set('plugin-1', 1); + const next_item_spy = vi.spyOn(spectator.component, 'nextItem'); + + spectator.component.onPluginError( + { code: 'boom', fatal: true, message: 'Boom' }, + 1, + ); + + expect(next_item_spy).not.toHaveBeenCalled(); + expect(spectator.component.index()).toBe(0); + }); + + it('should play a lone play-through plugin again once it finishes', () => { + vi.useFakeTimers(); + load_playlist([ + create_item('plugin-1', { + type: 'plugin', + plugin: { + id: 'plugin-1', + name: 'Story', + uri: 'https://plugins.example/story', + playback_type: 'playsthrough', + } as SignagePlugin, + }), + ]); + spectator.component.state.set('PLAYING'); + const output = spectator.component['_item_output'].get('plugin-1'); + spectator.component.onPluginStatus('ready', output); + vi.advanceTimersByTime(2_000); + vi.advanceTimersToNextFrame(); + spectator.component['_setOutputPluginPlay'](output, 0); + + spectator.component.onPluginFinished(output); + spectator.component['_updateItem'](); + + expect( + spectator.component.output_plugin_plays()[output], + ).toBeGreaterThan(0); + expect(spectator.component['_plugin_finished']).toBe(false); + }); + + it('should load a preloaded plugin afresh after it failed', () => { + const plugin = { + id: 'plugin-1', + name: 'Weather', + uri: 'https://plugins.example/weather', + } as SignagePlugin; + const plugin_item = create_item('plugin-1', { type: 'plugin', plugin }); + load_playlist([create_item('image-1'), plugin_item]); + spectator.component['_clearDeferredReveal'](); + // Preloaded on the inactive output, where it reported ready + spectator.component['_output_items'][1] = plugin_item; + spectator.component['_item_output'].set('plugin-1', 1); + spectator.component.output_plugins.set([null, plugin]); + spectator.component.onPluginStatus('ready', 1); + const next_item_spy = vi.spyOn(spectator.component, 'nextItem'); + + spectator.component.onPluginError( + { code: 'boom', fatal: true, message: 'Boom' }, + 1, + ); + + expect(next_item_spy).not.toHaveBeenCalled(); + expect(spectator.component.output_plugins()[1]).toBeNull(); + // Not preloaded again in the final seconds of the current item + spectator.component['_item_real_start'] = Date.now() - 6_000; + spectator.component['_processURLs'](); + expect(spectator.component.output_plugins()[1]).toBeNull(); + + // Its turn: loaded again rather than revealed from the failed frame + spectator.component.setPlaylistItem(1); + expect(spectator.component.output_plugins()[1]).toBe(plugin); + expect( + spectator.component['_ready_output_items'].has( + spectator.component['_outputKey'](1, plugin_item), + ), + ).toBe(false); + }); + + it('should use the new source when an edit changes it during a fetch', async () => { + vi.useFakeTimers(); + let resolve_old: (url: string) => void = () => undefined; + load_playlist([ + create_item('a', { + url: 'old-url', + getURL: () => + new Promise((resolve) => (resolve_old = resolve)), + }), + ]); + + load_playlist([ + create_item('a', { + url: 'new-url', + getURL: async () => 'blob:new', + }), + ]); + await vi.advanceTimersByTimeAsync(0); + resolve_old('blob:old'); + await vi.advanceTimersByTimeAsync(0); + + expect(spectator.component.url('a')).toBe('blob:new'); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:old'); + }); + + it('should replay a lone play-through plugin every time it finishes', () => { + vi.useFakeTimers(); + const origin = 'https://plugins.example'; + load_playlist([ + create_item('plugin-1', { + type: 'plugin', + plugin: { + id: 'plugin-1', + name: 'Story', + uri: `${origin}/story`, + playback_type: 'playsthrough', + } as SignagePlugin, + }), + ]); + spectator.component.state.set('PLAYING'); + const output = spectator.component['_item_output'].get('plugin-1'); + spectator.detectChanges(); + const frame = spectator.query( + 'plugin-embed iframe', + ) as HTMLIFrameElement; + /** Deliver a message through the real wrapper, as the plugin frame would */ + const from_plugin = (type: string) => + window.dispatchEvent( + new MessageEvent('message', { + data: { api: 'signage-plugin/v1', type }, + origin, + source: frame.contentWindow, + }), + ); + from_plugin('ready'); + vi.advanceTimersByTime(2_000); + vi.advanceTimersToNextFrame(); + + for (let round = 0; round < 3; round++) { + spectator.component['_setOutputPluginPlay'](output, 0); + from_plugin('finished'); + spectator.component['_updateItem'](); + + expect( + spectator.component.output_plugin_plays()[output], + ).toBeGreaterThan(0); + } + }); + + describe('a lone item that is held on screen', () => { + const passes = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.filter( + ([event]) => + (event as { type: string }).type === 'playlist_through', + ).length; + + it('should report one pass of a lone webpage once its duration has passed', async () => { + vi.useFakeTimers(); + const event_spy = vi.spyOn(spectator.component.event, 'emit'); + load_playlist([create_item('web', { type: 'webpage' })]); + await vi.advanceTimersByTimeAsync(500); + spectator.component.onWebpageLoad(0); + vi.advanceTimersByTime(3_000); + + vi.advanceTimersByTime(14_000); + spectator.component['_updateItem'](); + expect(passes(event_spy)).toBe(0); + + vi.advanceTimersByTime(1_001); + spectator.component['_updateItem'](); + spectator.component['_updateItem'](); + expect(passes(event_spy)).toBe(1); + // Still held, not reloaded + expect(spectator.component.index()).toBe(0); + }); + + it('should report one pass of a lone plugin once its duration has passed', () => { + vi.useFakeTimers(); + const event_spy = vi.spyOn(spectator.component.event, 'emit'); + load_playlist([ + create_item('plugin-1', { + type: 'plugin', + plugin: { + id: 'plugin-1', + name: 'Weather', + uri: 'https://plugins.example/weather', + playback_type: 'interactive', + } as SignagePlugin, + }), + ]); + spectator.component.state.set('PLAYING'); + const output = spectator.component['_item_output'].get('plugin-1'); + spectator.component.onPluginStatus('ready', output); + vi.advanceTimersByTime(2_000); + vi.advanceTimersToNextFrame(); + + vi.advanceTimersByTime(15_001); + spectator.component['_updateItem'](); + spectator.component['_updateItem'](); + + expect(passes(event_spy)).toBe(1); + }); + + it('should report a pass of a lone play-through plugin that finishes early', () => { + vi.useFakeTimers(); + const event_spy = vi.spyOn(spectator.component.event, 'emit'); + load_playlist([ + create_item('plugin-1', { + type: 'plugin', + plugin: { + id: 'plugin-1', + name: 'Story', + uri: 'https://plugins.example/story', + playback_type: 'playsthrough', + } as SignagePlugin, + }), + ]); + spectator.component.state.set('PLAYING'); + const output = spectator.component['_item_output'].get('plugin-1'); + spectator.component.onPluginStatus('ready', output); + vi.advanceTimersByTime(2_000); + vi.advanceTimersToNextFrame(); + + // Well inside its 15 second scheduled duration + vi.advanceTimersByTime(3_000); + spectator.component.onPluginFinished(output); + spectator.component['_updateItem'](); + + expect(passes(event_spy)).toBe(1); + }); + }); + + it('should not configure a plugin again when it was ready before it became active', async () => { + vi.useFakeTimers(); + const plugin = { + id: 'plugin-2', + name: 'News', + uri: 'https://plugins.example/news', + } as SignagePlugin; + const plugin_item = create_item('plugin-2', { type: 'plugin', plugin }); + load_playlist([create_item('image-1'), plugin_item]); + await vi.advanceTimersByTimeAsync(500); + // Preloaded on the inactive output, where it reported ready + spectator.component['_output_items'][1] = plugin_item; + spectator.component['_item_output'].set('plugin-2', 1); + spectator.component.output_plugins.set([null, plugin]); + spectator.component.onPluginStatus('ready', 1); + + spectator.component.setPlaylistItem(1); + vi.advanceTimersByTime(2_000); + vi.advanceTimersToNextFrame(); + const config = spectator.component.output_plugin_configs()[1]; + const play = spectator.component.output_plugin_plays()[1]; + expect(play).toBeGreaterThan(0); + + vi.advanceTimersByTime(15_000); + + expect(spectator.component.output_plugin_configs()[1]).toBe(config); + expect(spectator.component.output_plugin_plays()[1]).toBe(play); + }); + + it('should apply a muted input change to the video elements', () => { + spectator.setInput('muted', true); + + expect( + spectator.component['_video_element'](0).nativeElement.muted, + ).toBe(true); + expect( + spectator.component['_video_element'](1).nativeElement.muted, + ).toBe(true); + }); + + it('should check media validity without formatting dates', () => { + const format_spy = vi.spyOn(Intl, 'DateTimeFormat'); + const expired = create_item('expired', { valid_until: 1 }); + + expect(spectator.component.isValidMedia(expired)).toBe(false); + expect(format_spy).not.toHaveBeenCalled(); + }); + + it('should sandbox webpage frames', () => { + for (const output of [0, 1] as const) { + const web_el = + spectator.component['_web_element'](output).nativeElement; + expect(web_el.getAttribute('sandbox')).toBe( + 'allow-scripts allow-same-origin allow-forms', + ); + } + }); +}); + +describe('MediaPlayerComponent in a host', () => { + const create_host = createHostFactory({ + component: MediaPlayerComponent, + shallow: true, + }); + + it('should start muted when first bound muted', () => { + const host = create_host(``); + + for (const output of [0, 1] as const) { + expect( + host.component['_video_element'](output).nativeElement.muted, + ).toBe(true); + } + }); }); diff --git a/libs/components/src/lib/plugin-embed.component.ts b/libs/components/src/lib/plugin-embed.component.ts index 81ba26a63e6..fa8746c788f 100644 --- a/libs/components/src/lib/plugin-embed.component.ts +++ b/libs/components/src/lib/plugin-embed.component.ts @@ -143,6 +143,11 @@ export class PluginEmbedComponent 'unknown', ); public readonly loaded = output(); + /** + * Emits for every `finished` message. `statusChange` only emits when the + * status changes, so it misses a plugin that finishes again after a replay. + */ + public readonly finished = output(); public readonly plugin_error = output(); public readonly plugin_interaction = output(); private readonly _plugin_el = @@ -298,6 +303,9 @@ export class PluginEmbedComponent case 'error': this.plugin_error.emit(msg.payload); break; + case 'finished': + this.finished.emit(); + break; } } diff --git a/libs/components/src/tests/plugin-embed-thumbnail.spec.ts b/libs/components/src/tests/plugin-embed-thumbnail.spec.ts index 3401abde860..66ee2b2afba 100644 --- a/libs/components/src/tests/plugin-embed-thumbnail.spec.ts +++ b/libs/components/src/tests/plugin-embed-thumbnail.spec.ts @@ -106,6 +106,15 @@ describe('PluginEmbedComponent thumbnails', () => { ); }); + it('should emit finished for every finished message', () => { + const finished_spy = vi.spyOn(spectator.component.finished, 'emit'); + + fromPlugin({ type: 'finished' }); + fromPlugin({ type: 'finished' }); + + expect(finished_spy).toHaveBeenCalledTimes(2); + }); + it('should not let a thumbnail reply change the plugin status', async () => { declareCapabilities(true); fromPlugin({ type: 'ready' });