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
24 changes: 14 additions & 10 deletions apps/signage/DEBUGGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ questions without needing to reproduce anything.
| `media_cache` | Per file `status`, `size`, `owners`; plus totals, budget (`limit_bytes`), `too_large`, `failed_sync_attempts` |
| `watchdog` | Heartbeats for `poll` / `schedule` / `playback`, which are `stalled`, the last fatal error, and the recovery count and throttle state |
| `players` | Per player: `state`, `item_index`, `progress_percent`, `playing`, `queue`, `mid_play_through` |
| `remote_paused` | Whether the parent frame has paused playback with `signage:pause` |

Timestamps are ISO strings; `"never"` means it has not happened yet.

Expand Down Expand Up @@ -85,7 +86,8 @@ makes the display request use `?preview=true`.
| Old version running | `updates.new_version`, `updates.reload_pending` (a reload waits for the network and for play-through content to finish), `updates.last_check` |
| Blank screen after a reboot | Likely offline boot — check `online`, then whether cached credentials exist |
| Player reloading itself | `watchdog.recent_reloads` and `watchdog.last_error` — something fatal stalled a core loop |
| Paused and does not resume | Pause and resume messages are obeyed only from the parent frame. Check what embeds the player and `players[].state` |
| Paused and does not resume | Pause and resume messages are obeyed only from the parent frame. `remote_paused` stays true until it sends `signage:resume` |
| Template or plugin bands missing | A failed template, plugin or background load tries again after 15 s, doubling to 5 min. Look for `Unable to load template` |
| Plugin cut short, or held long | A play-through plugin advances on `finished`, or after a limit. Look for `did not report finished in time` in the console |
| Blank screen, no `window.signage` | The application did not start. Look for `Application failed to start` in the console; it reloads with a backoff |
| Media always streams | `media_cache.too_large` — the file does not fit in `limit_bytes`; see [Media cache storage](#media-cache-storage) |
Expand Down Expand Up @@ -180,15 +182,17 @@ recovered and you want to know what from.

## Storage

| Location | Holds |
| `localStorage["PlaceOS.SIGNAGE.display_details.<id>"]` | Last known display payload, used offline |
| `localStorage["PlaceOS.SIGNAGE.cached_files"]` | Media cache index (urls, sizes, owners) |
| `localStorage["PlaceOS.SIGNAGE.display"]` | Bootstrapped display id |
| `localStorage["PLACEOS.org.*"]` | Cached zone data and last known authority |
| `localStorage["PlaceOS.SIGNAGE.watchdog_reloads"]` | Timestamps of automatic recoveries |
| `sessionStorage["SIGNAGE.debug"]`, `["SIGNAGE.muted"]` | Debug and mute state |
| `sessionStorage["SIGNAGE.boot_failures"]` | Consecutive failed starts, for the backoff |
| IndexedDB `SignageMedia` → `files` | The cached media files themselves |
| Location | Holds |
| ------------------------------------------------------ | ---------------------------------------------------- |
| `localStorage["PlaceOS.SIGNAGE.display_details.<id>"]` | Last known display payload, used offline |
| `localStorage["PlaceOS.SIGNAGE.cached_files"]` | Media cache index (urls, sizes, owners) |
| `localStorage["PlaceOS.SIGNAGE.display"]` | Bootstrapped display id |
| `localStorage["PLACEOS.org.*"]` | Cached zone data and last known authority |
| `localStorage["PlaceOS.SIGNAGE.watchdog_reloads"]` | Timestamps of automatic recoveries |
| `sessionStorage["SIGNAGE.debug"]`, `["SIGNAGE.muted"]` | Debug and mute state |
| `sessionStorage["SIGNAGE.boot_failures"]` | Consecutive failed starts, for the backoff |
| `localStorage["SIGNAGE.debug-overlay.<id>"]` | Debug overlay positions, kept after the session ends |
| IndexedDB `SignageMedia` → `files` | The cached media files themselves |

## Media cache storage

Expand Down
4 changes: 3 additions & 1 deletion apps/signage/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device
- The override ends at the scheduled end time when `play_period` is greater than zero.
- A scheduled takeover with `play_period` set to zero uses a short activation window, plays a single pass, and then clears.
- A single-pass takeover continues after its activation window closes. It clears when the pass is complete, or when its playlist is removed, disabled, or no longer a single-pass takeover.
- When single-pass takeovers share one override, the pass is complete when the playlist of the last valid item plays through.
- Clearing a scheduled override records its schedule key so the same activation is not immediately retriggered.

---
Expand Down Expand Up @@ -433,7 +434,8 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device
- Only messages from the parent frame are accepted. Messages from other windows, such as webpage or plugin content on screen, are ignored.
- A player that is not in a frame ignores all pause and resume messages.
- A payload with `type: 'signage:pause'` pauses all player instances.
- A payload with `type: 'signage:resume'` resumes all player instances.
- A payload with `type: 'signage:resume'` resumes all player instances. The normal player stays paused while a takeover plays.
- While paused, a takeover that starts stays paused, and the normal player stays paused when a takeover ends.
- Unknown payloads are ignored.
- The message listener is removed when the panel is destroyed.

Expand Down
8 changes: 4 additions & 4 deletions apps/signage/src/app/debug-state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/**
* Whether the player is being debugged.
* Session storage key for debug mode.
*
* Debug mode is turned on with a `debug` query parameter and remembered for
* the browser session, so it can be read here before the panel that normally
* handles that parameter exists. Kept in step with `signage.component.ts`.
* handles that parameter exists.
*/
const DEBUG_KEY = 'SIGNAGE.debug';
export const DEBUG_STORAGE_KEY = 'SIGNAGE.debug';

/** A `debug` parameter counts as on unless it is explicitly false */
export function isDebugEnabled(value: string | null) {
Expand All @@ -18,7 +18,7 @@ export function isDebugMode(): boolean {
const query = location.hash.split('?')[1] || location.search.slice(1);
const params = new URLSearchParams(query);
if (params.has('debug')) return isDebugEnabled(params.get('debug'));
return isDebugEnabled(sessionStorage.getItem(DEBUG_KEY));
return isDebugEnabled(sessionStorage.getItem(DEBUG_STORAGE_KEY));
} catch {
return false;
}
Expand Down
55 changes: 46 additions & 9 deletions apps/signage/src/app/signage.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DatePipe } from '@angular/common';
import {
afterRenderEffect,
Component,
inject,
input,
Expand All @@ -24,9 +25,9 @@ import {
clearDebugOverlayLayouts,
DebugOverlayComponent,
} from './debug-overlay.component';
import { isDebugEnabled } from './debug-state';
import { DEBUG_STORAGE_KEY, isDebugEnabled } from './debug-state';
import { registerSignageDiagnostics } from './diagnostics';
import { time } from './media-helpers';
import { time, validateMedia } from './media-helpers';
import { MediaPlayerComponent } from './media-player.component';
import { MediaEvent, SignageService } from './signage.service';
import { recordHeartbeat } from './watchdog';
Expand Down Expand Up @@ -229,6 +230,8 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
public readonly debug = this._signage.debug;
public readonly playing_id = this._signage.playing_id;
public readonly muted = signal(true);
/** Whether the parent shell has paused playback */
public readonly remote_paused = signal(false);
public readonly debug_layout_editing = signal(false);
public readonly debug_layout_reset_count = signal(0);
public readonly transparent = input(false);
Expand All @@ -237,6 +240,19 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {

private readonly _players = viewChildren(MediaPlayerComponent);

/**
* Keep every player paused while the shell has paused playback. Players
* also start on their own: a takeover mounts playing, and the background
* resumes when a takeover ends. This runs after rendering, so it sees the
* players once they have reacted to the change.
*/
private readonly _hold_remote_pause = afterRenderEffect(() => {
if (!this.remote_paused()) return;
for (const player of this._players()) {
if (player.state() === 'PLAYING') player.togglePause();
}
});

public readonly clearOverridePlaylist = () =>
this._signage.clearPlaylistOverride();

Expand All @@ -260,12 +276,16 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
if (window.parent === window || event?.source !== window.parent) return;
const data = event?.data;
if (!data || typeof data !== 'object') return;
if (data.type === REMOTE_PAUSE) this._setPlaybackState('PAUSED');
else if (data.type === REMOTE_RESUME) this._setPlaybackState('PLAYING');
if (data.type === REMOTE_PAUSE) this._setRemotePaused(true);
else if (data.type === REMOTE_RESUME) this._setRemotePaused(false);
};

private _setPlaybackState(target: 'PAUSED' | 'PLAYING') {
private _setRemotePaused(paused: boolean) {
this.remote_paused.set(paused);
const target = paused ? 'PAUSED' : 'PLAYING';
for (const player of this._players()) {
// The background stays paused while a takeover plays over it
if (!paused && player.override()) continue;
if (player.state() !== target) player.togglePause();
}
}
Expand Down Expand Up @@ -314,7 +334,7 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
},
3000,
);
const debug = sessionStorage.getItem('SIGNAGE.debug');
const debug = sessionStorage.getItem(DEBUG_STORAGE_KEY);
if (debug !== null) this.debug.set(isDebugEnabled(debug));
const muted = sessionStorage.getItem(MUTE_STORAGE_KEY);
if (muted !== null) this.muted.set(muted === 'true');
Expand All @@ -324,7 +344,7 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
if (params.has('debug')) {
const enabled = isDebugEnabled(params.get('debug'));
this.debug.set(enabled);
sessionStorage.setItem('SIGNAGE.debug', `${enabled}`);
sessionStorage.setItem(DEBUG_STORAGE_KEY, `${enabled}`);
}
}),
);
Expand Down Expand Up @@ -359,6 +379,7 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
},
online: isOnline(),
updates: updateCheckState(),
remote_paused: this.remote_paused(),
...this._signage.diagnostics(),
players: this._players().map((player, index) => ({
role: index === 0 ? 'background' : 'takeover',
Expand All @@ -382,11 +403,27 @@ export class SignagePanelComponent extends AsyncHandler implements OnInit {
}

public handlePlayerEvent(e: MediaEvent, overridden = false) {
// Check override playlists for single play throughs
// A single-pass override ends after one full pass
if (overridden && e.type === 'playlist_through') {
const { ends_at } = this.override_playlist();
if (!ends_at) this._signage.clearPlaylistOverride();
if (!ends_at && this._isOverridePassDone(e.ref_id)) {
this._signage.clearPlaylistOverride();
}
}
this._signage.storeMetricEvent(e);
}

/**
* Whether a `playlist_through` ends the pass of the override. Overlapping
* single-pass runs share one override, and the player reports each of
* their playlists as it passes, so the pass is done when the playlist of
* the last valid item finishes. With no valid item there is nothing left
* to play.
*/
private _isOverridePassDone(playlist_id: string) {
const last_valid = this.override_playlist()
.playlist.filter((item) => !validateMedia(item))
.at(-1);
return !last_valid || last_valid.playlist === playlist_id;
}
}
Loading
Loading