Skip to content
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
15 changes: 9 additions & 6 deletions apps/signage/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

---
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 15 additions & 13 deletions apps/signage/src/app/media-controls.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'
Expand Down Expand Up @@ -232,22 +233,23 @@ export class MediaProgressBarComponent implements OnInit, OnDestroy {
imports: [
IconComponent,
TranslatePipe,
MatRippleModule,
MatTooltipModule,
MatProgressSpinnerModule,
MediaDurationPipe,
MediaProgressBarComponent,
],
})
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<MediaLoop>('NONE');
public readonly state = model<MediaPlayerState>('PAUSED');
public readonly shuffle = model(false);
public readonly event = output<MediaEvent>();
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<MediaLoop>('NONE');
public readonly state = input<MediaPlayerState>('PAUSED');
public readonly shuffle = input(false);
public readonly event = output<MediaControlEvent>();
}
17 changes: 13 additions & 4 deletions apps/signage/src/app/media-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading