mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Foundation for platform-native parity. The dashboard offered every control to every display — a browser tab cannot reboot its host, a Tizen TV has no device-owner concept, a BrightSign has no per-window brightness — so those buttons did nothing, silently, and read as bugs. "UI that reports success and changes nothing" is a recurring shape here; this ends it by letting the frontend hide what a display cannot do. The player DECLARES its capabilities at registration rather than the server inferring them from a table, because only the player knows at runtime: an Android device gains real screenshots when accessibility is switched on and loses Tier-2 commands when it is not device owner. The trap this had to avoid is the opposite failure. Several hundred displays are in the field declaring nothing, and none will update before the next dashboard deploy — treating absence as "supports nothing" would strip the UI for the entire fleet at once. So an ABSENT declaration falls back to a per-platform baseline, while an EMPTY one is honoured as a player genuinely saying it can do nothing. Those two cases are trivial to conflate and the difference is a dark dashboard. Baselines carry only what has always worked on that platform. Anything conditional — screenshots needing accessibility, kiosk needing device owner, native sync needing one L2 network — is omitted, so a legacy display shows those controls only once it declares them. A control that appears late beats one that lies now. Capability names are stable strings because they are persisted per device and sent over the wire; renaming one silently disables a control on every display still reporting the old name. An unknown name from a NEWER player is dropped rather than invalidating the whole declaration. 1094 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
159 lines
6.5 KiB
JavaScript
159 lines
6.5 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* What a player can actually do.
|
|
*
|
|
* The dashboard offered every control to every display. A browser tab cannot reboot its host, a
|
|
* Tizen TV has no device-owner concept, a BrightSign has no per-window brightness — so those
|
|
* buttons did nothing, silently, and looked like bugs. "UI that reports success and changes
|
|
* nothing" is a recurring shape in this codebase and this module exists to end it.
|
|
*
|
|
* The player DECLARES its capabilities at registration, because only the player knows at runtime:
|
|
* an Android device gains real screenshots when accessibility is switched on, and loses Tier-2
|
|
* commands when it is not device owner. A static per-platform table could never know that.
|
|
*
|
|
* ⚠️ Legacy displays declare nothing. A fleet of several hundred is not going to update before the
|
|
* next dashboard deploy, so an absent declaration falls back to a per-platform baseline rather
|
|
* than to "supports nothing" — which would strip the UI for every existing display at once. The
|
|
* baseline is deliberately optimistic for things that always worked, and pessimistic for anything
|
|
* that depends on runtime state.
|
|
*/
|
|
|
|
/*
|
|
* The vocabulary. Stable strings, because they are persisted per device and sent over the wire —
|
|
* renaming one silently disables a control on every display that still reports the old name.
|
|
* Grouped by what the operator is trying to do, not by how it is implemented.
|
|
*/
|
|
const CAPABILITIES = [
|
|
// playback surface
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
// audio
|
|
'audio.mute', 'audio.volume',
|
|
// display
|
|
'display.rotation', 'display.power', 'display.resolution',
|
|
// remote view / control
|
|
'remote.screenshot', 'remote.stream', 'remote.input',
|
|
// lifecycle
|
|
'system.reboot', 'system.restart_player', 'system.self_update',
|
|
// device management (Android device-owner territory)
|
|
'system.kiosk', 'system.brightness', 'system.screen_timeout',
|
|
'system.install_apk', 'system.shell', 'system.time',
|
|
// synchronisation
|
|
'sync.clock', 'sync.native',
|
|
// resilience
|
|
'offline.cache',
|
|
];
|
|
|
|
const CAP_SET = new Set(CAPABILITIES);
|
|
|
|
/*
|
|
* Baselines for displays that declare nothing.
|
|
*
|
|
* Only things that have always worked on that platform. Anything conditional — screenshots that
|
|
* need accessibility, kiosk that needs device owner, native sync that needs one L2 network — is
|
|
* omitted, so a legacy display shows those controls only once it declares them. Better a control
|
|
* that appears late than one that lies today.
|
|
*/
|
|
const BASELINE = {
|
|
android: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute', 'audio.volume',
|
|
'display.rotation', 'display.power',
|
|
'remote.screenshot', 'remote.stream', 'remote.input',
|
|
'system.reboot', 'system.restart_player', 'system.self_update',
|
|
'sync.clock', 'offline.cache',
|
|
],
|
|
tizen: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute', 'audio.volume',
|
|
'display.rotation',
|
|
'remote.input',
|
|
'system.restart_player',
|
|
'sync.clock', 'offline.cache',
|
|
],
|
|
brightsign: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute', 'audio.volume',
|
|
'display.rotation', 'display.power',
|
|
'remote.input',
|
|
'system.reboot', 'system.restart_player',
|
|
'sync.clock', 'offline.cache',
|
|
],
|
|
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
|
|
// capture anything outside its own document.
|
|
web: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute', 'audio.volume',
|
|
'display.rotation',
|
|
'remote.screenshot', 'remote.stream', 'remote.input',
|
|
'system.restart_player',
|
|
'sync.clock', 'offline.cache',
|
|
],
|
|
};
|
|
|
|
/*
|
|
* Which baseline a device falls back to. Keyed off the same `platform` field the sync resolver
|
|
* uses, so a device is classified one way across the whole product.
|
|
*/
|
|
function platformFamily(device) {
|
|
const platform = String((device && device.platform) || '').toLowerCase();
|
|
const android = String((device && device.android_version) || '');
|
|
if (platform.includes('brightsign')) return 'brightsign';
|
|
if (platform.includes('tizen')) return 'tizen';
|
|
// client_type 'apk' is the Android player; android_version that is NOT the web player's
|
|
// "Web/..." shape is the older signal for the same thing.
|
|
if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android';
|
|
return 'web';
|
|
}
|
|
|
|
/**
|
|
* The capability set for a device, as an array of known capability strings.
|
|
*
|
|
* @param {object} device a device row; may carry `capabilities` (JSON array or string)
|
|
* @returns {string[]}
|
|
*/
|
|
function capabilitiesFor(device) {
|
|
const declared = parseDeclared(device && device.capabilities);
|
|
if (declared) return declared;
|
|
return (BASELINE[platformFamily(device)] || BASELINE.web).slice();
|
|
}
|
|
|
|
/**
|
|
* True when the device supports `cap`. Unknown capability names are always false.
|
|
*
|
|
* A missing device supports nothing. It would otherwise fall through to the web baseline and
|
|
* claim video playback for a row that does not exist — a caller rendering controls from a failed
|
|
* lookup should get an empty panel, not a plausible-looking one.
|
|
*/
|
|
function supports(device, cap) {
|
|
if (!device) return false;
|
|
if (!CAP_SET.has(cap)) return false;
|
|
return capabilitiesFor(device).includes(cap);
|
|
}
|
|
|
|
/*
|
|
* Parse whatever the device sent. Returns null when there is no usable declaration, which is the
|
|
* signal to fall back to the baseline — distinct from an EMPTY declaration, which is a player
|
|
* genuinely saying "I can do nothing" and must be honoured.
|
|
*/
|
|
function parseDeclared(raw) {
|
|
if (raw === null || raw === undefined) return null;
|
|
let list = raw;
|
|
if (typeof raw === 'string') {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
try { list = JSON.parse(trimmed); } catch (e) { return null; }
|
|
}
|
|
if (!Array.isArray(list)) return null;
|
|
// Unknown strings are dropped rather than rejected wholesale: a newer player declaring a
|
|
// capability this server has never heard of must not lose the ones it does understand.
|
|
return list.filter((c) => CAP_SET.has(c));
|
|
}
|
|
|
|
module.exports = { CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared };
|