mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1148 lines
63 KiB
JavaScript
1148 lines
63 KiB
JavaScript
// Core playback capability domain host.
|
|
(function () {
|
|
'use strict';
|
|
|
|
window.feedBack = window.feedBack || {};
|
|
const capabilities = window.feedBack.capabilities;
|
|
if (!capabilities || capabilities.version !== 1) return;
|
|
if (window.feedBack.playback && window.feedBack.playback.version === 1) return;
|
|
|
|
const SCHEMA = 'feedBack.playback.diagnostics.v1';
|
|
const OWNER_ID = 'core.playback';
|
|
const MAX_CURRENT_OUTCOMES = 50;
|
|
const MAX_CURRENT_EVENTS = 50;
|
|
const MAX_STOPPED_SESSIONS = 5;
|
|
const MAX_STOPPED_SESSION_ITEMS = 20;
|
|
const MAX_BRIDGES = 50;
|
|
// Max retained requesters / observers per role. The registration surface is
|
|
// otherwise unbounded, so without this a flood would expand the maps
|
|
// (memory) and the per-snapshot work without limit.
|
|
const MAX_PARTICIPANTS = 50;
|
|
// Generic redaction backstop for arbitrary nested arrays. Kept >= the
|
|
// accounted history caps above so _safeValue never silently re-trims an
|
|
// already-bounded history array (which would make redaction.droppedCounts
|
|
// under-report); it only bounds otherwise-unbounded arrays (e.g. participants).
|
|
const MAX_SAFE_ARRAY = 50;
|
|
const MAX_REASON = 240;
|
|
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
const ACTIVE_STATES = new Set(['loading', 'ready', 'playing', 'paused', 'seeking', 'degraded']);
|
|
const TERMINAL_STATES = new Set(['idle', 'ended', 'stopped', 'unavailable', 'failed']);
|
|
const OUTCOMES = new Set([
|
|
'handled', 'denied', 'degraded', 'failed', 'no-owner', 'no-handler', 'no-target',
|
|
'unsupported-command', 'incompatible-version', 'unavailable', 'user-action-required',
|
|
'stale', 'cancelled', 'stopped', 'overridden',
|
|
]);
|
|
const EVENT_NAMES = Object.freeze([
|
|
'requested', 'loading', 'ready', 'started', 'paused', 'resumed', 'seeking', 'seeked',
|
|
'ended', 'stopped', 'unavailable', 'degraded', 'failed', 'superseded', 'route-changing',
|
|
'route-changed', 'bridge-hit', 'loop-set', 'loop-cleared', 'loop-restarted',
|
|
'loop-rejected', 'loop-stale',
|
|
]);
|
|
const COMMANDS = Object.freeze(['inspect', 'start', 'pause', 'resume', 'stop', 'seek', 'set-loop', 'clear-loop', 'register-requester', 'register-observer']);
|
|
const RAW_KEY_RE = /(^|_)(title|artist|album|filename|path|url|audio|stream|media_stream|audio_node|audionode|node|device|device_id|hardware|hardware_id|handle|buffer|sample|waveform|recording|native|route_object|secret|token|password|api|api_key)(_|$)/i;
|
|
|
|
let sequence = 0;
|
|
let commandSequence = 0;
|
|
let transportAdapter = null;
|
|
const requesters = new Map();
|
|
const observers = new Map();
|
|
const bridges = new Map();
|
|
const stoppedSessions = [];
|
|
let lastUserAction = null;
|
|
let pendingSeekWasPlaying = null;
|
|
let commandAdapterDepth = 0;
|
|
let currentSession = _newIdleSession();
|
|
|
|
// Run a command's transport-adapter call while flagging that any resulting
|
|
// legacy song:* events are echoes of this domain's own command — the legacy
|
|
// bridge skips them so observers don't get duplicate playback:* events
|
|
// (and stop doesn't double-archive). Genuine UI-driven song:* events, which
|
|
// fire outside any command, still flow through the bridge.
|
|
//
|
|
// For the fast commands the echo is emitted synchronously inside the
|
|
// adapter call, so no other handler can interleave; only start's longer
|
|
// load window holds the flag across awaits. In this single-user app (no
|
|
// concurrency model, per the constitution) a real user-originated legacy
|
|
// event landing inside that window is not expected, and missing one bridge
|
|
// reflection is benign — the legacy UI handlers still apply it directly.
|
|
async function _driveAdapter(fn) {
|
|
commandAdapterDepth += 1;
|
|
try { return await fn(); }
|
|
finally { commandAdapterDepth -= 1; }
|
|
}
|
|
|
|
function _now() { return new Date().toISOString(); }
|
|
|
|
function _string(value, fallback = '') {
|
|
const normalized = String(value == null ? '' : value).trim();
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function _number(value, fallback = null) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : fallback;
|
|
}
|
|
|
|
function _bool(value, fallback = false) {
|
|
if (value === true || value === false) return value;
|
|
return fallback;
|
|
}
|
|
|
|
function _plainObject(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function _id(prefix) {
|
|
sequence += 1;
|
|
return `${prefix}-${sequence}`;
|
|
}
|
|
|
|
function _safeInternalId(value, prefix, fallback = '') {
|
|
const raw = _string(value);
|
|
// Only the trusted internal fallback (this domain's own id) passes
|
|
// through. Any other caller-supplied id is untrusted — a charset match
|
|
// is not enough (e.g. `playback-secret_token`), so hash it so nothing
|
|
// identifying is exported verbatim in diagnostics/history.
|
|
if (!raw || raw === fallback) return fallback;
|
|
const safePrefix = _string(prefix, 'id');
|
|
return `${safePrefix}-${_hash(raw)}`;
|
|
}
|
|
|
|
function _hash(value) {
|
|
const input = _string(value, 'unknown');
|
|
let h = 2166136261;
|
|
for (let i = 0; i < input.length; i += 1) {
|
|
h ^= input.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return (h >>> 0).toString(36);
|
|
}
|
|
|
|
function _redactString(value) {
|
|
const localPath = /(?:\/Users\/|\/home\/|\/root\b\/?|[A-Za-z]:\\)[^\r\n\t"',;(){}\[\]<>|]*/g;
|
|
return _string(value)
|
|
.replace(localPath, '[path]')
|
|
.replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]')
|
|
.replace(/\b(token|secret|password|api[_-]?key)=([^\s&]+)/gi, '$1=[redacted]')
|
|
.replace(/\b(raw[-_ ]?audio|audio[-_ ]?buffer|sample[s]?|waveform[s]?|recording[s]?|htmlaudioelement|audionode|mediastream)\b/gi, '[media]');
|
|
}
|
|
|
|
function _boundedReason(value) {
|
|
return _redactString(value).replace(/\s+/g, ' ').slice(0, MAX_REASON);
|
|
}
|
|
|
|
function _requesterId(value, fallback = 'unknown') {
|
|
return _boundedReason(value || fallback).replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 96) || fallback;
|
|
}
|
|
|
|
function _safeKeyName(key) {
|
|
return _string(key).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').toLowerCase();
|
|
}
|
|
|
|
function _eventName(name) {
|
|
return _string(name).replace(/^playback:/, '');
|
|
}
|
|
|
|
function _priority(args) {
|
|
const source = _plainObject(args);
|
|
if (source.priority === 'user' || source.authorization === 'user-action' || source.kind === 'user') return 'user';
|
|
return 'normal';
|
|
}
|
|
|
|
function _payloadIsPlaying(source, fallback) {
|
|
const payload = _plainObject(source);
|
|
if (payload.isPlaying === true || payload.playing === true) return true;
|
|
if (payload.isPlaying === false || payload.playing === false) return false;
|
|
const state = _string(payload.playbackState || payload.transportState || payload.state).toLowerCase();
|
|
if (state === 'playing' || state === 'started' || state === 'resumed') return true;
|
|
if (state === 'paused' || state === 'stopped' || state === 'ended' || state === 'idle') return false;
|
|
return !!fallback;
|
|
}
|
|
|
|
function _safeValue(value, depth = 0, exportMode = 'exported') {
|
|
if (typeof value === 'string') return _boundedReason(value);
|
|
if (value == null || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
if (depth > 6) return '[truncated]';
|
|
// Keep the most recent items as a backstop for unbounded arrays. The
|
|
// cap is >= the explicit history caps, so accounted history (already
|
|
// bounded + budget-trimmed with droppedCounts) passes through untouched;
|
|
// slice(-N) keeps the latest activity rather than the oldest.
|
|
if (Array.isArray(value)) return value.slice(-MAX_SAFE_ARRAY).map(item => _safeValue(item, depth + 1, exportMode));
|
|
if (typeof value === 'object') {
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value).slice(0, 40)) {
|
|
if (typeof item === 'function') continue;
|
|
if (exportMode === 'local-inspector' && (key === 'localDisplay' || key === 'display')) {
|
|
out[key] = _safeLocalDisplay(item);
|
|
continue;
|
|
}
|
|
if (RAW_KEY_RE.test(_safeKeyName(key))) {
|
|
continue;
|
|
}
|
|
out[_boundedReason(key)] = _safeValue(item, depth + 1, exportMode);
|
|
}
|
|
return out;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function _safeLocalDisplay(value) {
|
|
const source = _plainObject(value);
|
|
return {
|
|
title: _boundedReason(source.title || source.name),
|
|
artist: _boundedReason(source.artist),
|
|
arrangement: _boundedReason(source.arrangement || source.arrangementName || source.arrangementSmartName),
|
|
};
|
|
}
|
|
|
|
function _clone(value) {
|
|
try { return JSON.parse(JSON.stringify(value)); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function _targetSeed(target) {
|
|
const source = _plainObject(target);
|
|
return source.songKey || source.libraryKey || source.id || source.filename || source.url || source.title || source.name || currentSession.target?.targetId || source.targetId || 'unknown';
|
|
}
|
|
|
|
function _settingsSeed(target) {
|
|
const source = _plainObject(target);
|
|
return source.songKey || source.libraryKey || source.id || source.filename || source.url || source.title || source.name || source.settingsKey || source.localSettingsKey || source.playbackSettingsKey || currentSession.target?.settingsKey || currentSession.target?.targetId || 'unknown';
|
|
}
|
|
|
|
function _settingsKey(target) {
|
|
const source = _plainObject(target);
|
|
const explicit = _string(source.settingsKey || source.localSettingsKey || source.playbackSettingsKey);
|
|
if (/^settings-v1-[a-z0-9]{7}$/.test(explicit)) return explicit;
|
|
const sourceKind = _boundedReason(source.sourceKind || source.source || source.format || 'unknown').slice(0, 40) || 'unknown';
|
|
return `settings-v1-${_hash(`${sourceKind}:${_settingsSeed(source)}`).padStart(7, '0').slice(-7)}`;
|
|
}
|
|
|
|
function _normalizeArrangement(source) {
|
|
// A numeric index is the canonical, non-identifying key — preserve 0
|
|
// ('||' would drop it as falsy).
|
|
const index = _number(source.arrangementIndex, _number(source.arrangement_index, null));
|
|
if (index != null) return `arrangement-${index}`;
|
|
// arrangementRef/arrangementId may already be our normalized form;
|
|
// pass one through only when it matches that exact shape
|
|
// (arrangement-<index|hash>), otherwise pseudonymize it.
|
|
const ref = source.arrangementRef || source.arrangementId;
|
|
if (typeof ref === 'number') return `arrangement-${ref}`;
|
|
if (typeof ref === 'string' && ref) {
|
|
const str = _boundedReason(ref).slice(0, 80);
|
|
return /^arrangement-[a-z0-9]+$/.test(str) ? str : `arrangement-${_hash(str)}`;
|
|
}
|
|
// A free-text arrangement label (e.g. "Lead") is user-visible, so always
|
|
// pseudonymize it — never emit it verbatim via target.arrangementRef.
|
|
const label = source.arrangement;
|
|
if (label == null || label === '') return '';
|
|
if (typeof label === 'number') return `arrangement-${label}`;
|
|
return `arrangement-${_hash(_boundedReason(label).slice(0, 80))}`;
|
|
}
|
|
|
|
function _normalizeTarget(target = {}, options = {}) {
|
|
const source = _plainObject(target);
|
|
const seed = _targetSeed(source);
|
|
const arrangementRef = _normalizeArrangement(source) || _normalizeArrangement(options);
|
|
const normalized = {
|
|
targetId: `target-${_hash(`${seed}:${arrangementRef}`)}`,
|
|
settingsKey: _settingsKey(source),
|
|
sourceKind: _boundedReason(source.sourceKind || source.source || source.format || 'unknown').slice(0, 40) || 'unknown',
|
|
arrangementRef: arrangementRef || null,
|
|
format: _boundedReason(source.format || source.sourceKind || 'unknown').slice(0, 40) || 'unknown',
|
|
requestedBy: _requesterId(source.requestedBy || options.requesterId || options.source, 'unknown'),
|
|
};
|
|
const display = _safeLocalDisplay(source.localDisplay || source.display || source);
|
|
if (display.title || display.artist || display.arrangement) normalized.localDisplay = display;
|
|
return normalized;
|
|
}
|
|
|
|
function _inactiveLoop(state = 'inactive') {
|
|
return { loopId: null, sessionId: null, startTime: null, endTime: null, enabled: false, state, requesterId: null, reason: '' };
|
|
}
|
|
|
|
function _defaultRoute() {
|
|
return { routeId: 'route-unknown', routeKind: 'unknown', state: 'unavailable', preservedTime: null, safeReason: '', lastChangedAt: _now() };
|
|
}
|
|
|
|
function _newIdleSession() {
|
|
const now = _now();
|
|
return {
|
|
sessionId: 'playback-idle',
|
|
sequence: 0,
|
|
state: 'idle',
|
|
target: null,
|
|
transport: { state: 'idle', isPlaying: false, isSeeking: false, readiness: 'idle', requesterId: 'system', priority: 'normal', reason: '', updatedAt: now },
|
|
media: { targetId: null, currentTime: null, duration: null, playbackRate: null, chartTime: null, mediaTime: null, timeUncertainty: 'unknown', readiness: 'idle', route: _defaultRoute(), loop: _inactiveLoop() },
|
|
route: _defaultRoute(),
|
|
loop: _inactiveLoop(),
|
|
history: { recentOutcomes: [], lifecycleEvents: [], droppedCounts: { outcomes: 0, events: 0 }, updatedAt: now },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
stoppedAt: null,
|
|
};
|
|
}
|
|
|
|
function _newSession(target, args = {}) {
|
|
const now = _now();
|
|
const sessionId = _id('playback');
|
|
return {
|
|
sessionId,
|
|
sequence,
|
|
state: 'loading',
|
|
target,
|
|
transport: { state: 'loading', isPlaying: false, isSeeking: false, readiness: 'loading', requesterId: _requesterId(args.requesterId || args.source, 'core.player.controls'), priority: _priority(args), reason: _boundedReason(args.reason), updatedAt: now },
|
|
media: { targetId: target && target.targetId, currentTime: null, duration: null, playbackRate: 1, chartTime: null, mediaTime: null, timeUncertainty: 'unknown', readiness: 'loading', route: _defaultRoute(), loop: _inactiveLoop() },
|
|
route: _defaultRoute(),
|
|
loop: _inactiveLoop(),
|
|
history: { recentOutcomes: [], lifecycleEvents: [], droppedCounts: { outcomes: 0, events: 0 }, updatedAt: now },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
stoppedAt: null,
|
|
};
|
|
}
|
|
|
|
function _touch() {
|
|
currentSession.updatedAt = _now();
|
|
currentSession.transport.updatedAt = currentSession.updatedAt;
|
|
currentSession.media.targetId = currentSession.target && currentSession.target.targetId || null;
|
|
currentSession.media.route = _clone(currentSession.route);
|
|
currentSession.media.loop = _clone(currentSession.loop);
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function _rememberBounded(list, item, max, droppedKey) {
|
|
list.push(item);
|
|
while (list.length > max) {
|
|
list.shift();
|
|
if (currentSession.history && currentSession.history.droppedCounts) currentSession.history.droppedCounts[droppedKey] += 1;
|
|
}
|
|
if (currentSession.history) currentSession.history.updatedAt = _now();
|
|
}
|
|
|
|
function _recordOutcome(operation, outcome, details = {}) {
|
|
const normalized = OUTCOMES.has(outcome) ? outcome : 'handled';
|
|
const entry = {
|
|
operation: _boundedReason(operation).slice(0, 80),
|
|
outcome: normalized,
|
|
status: _boundedReason(details.status || normalized).slice(0, 80),
|
|
sessionId: _safeInternalId(details.sessionId, 'playback', currentSession.sessionId),
|
|
targetId: details.targetId || (currentSession.target && currentSession.target.targetId) || null,
|
|
requesterId: _requesterId(details.requesterId || details.source || currentSession.transport.requesterId, 'unknown'),
|
|
reason: _boundedReason(details.reason),
|
|
createdAt: _now(),
|
|
};
|
|
for (const key of ['requestedTime', 'fromTime', 'landedTime']) {
|
|
const numeric = _number(details[key], null);
|
|
if (numeric != null) entry[key] = numeric;
|
|
}
|
|
_rememberBounded(currentSession.history.recentOutcomes, entry, MAX_CURRENT_OUTCOMES, 'outcomes');
|
|
_touch();
|
|
return entry;
|
|
}
|
|
|
|
function _recordLifecycle(event, payload = {}) {
|
|
const name = _eventName(event);
|
|
const summary = {
|
|
event: `playback:${name}`,
|
|
sessionId: currentSession.sessionId,
|
|
targetId: currentSession.target && currentSession.target.targetId || null,
|
|
state: currentSession.state,
|
|
requesterId: _requesterId(payload.requesterId || payload.source || currentSession.transport.requesterId, 'unknown'),
|
|
reason: _boundedReason(payload.reason),
|
|
createdAt: _now(),
|
|
};
|
|
_rememberBounded(currentSession.history.lifecycleEvents, summary, MAX_CURRENT_EVENTS, 'events');
|
|
return summary;
|
|
}
|
|
|
|
function _emitPlayback(event, payload = {}) {
|
|
const name = _eventName(event);
|
|
// Sanitized caller payload first, canonical session fields last, so a
|
|
// caller-/transport-controlled payload (e.g. via transportEvent or
|
|
// recordRouteChange) can never override the authoritative sessionId,
|
|
// state, target, media, loop, or route and spoof the event identity.
|
|
const safePayload = {
|
|
..._safeValue(payload, 0, 'exported'),
|
|
sessionId: currentSession.sessionId,
|
|
state: currentSession.state,
|
|
target: _publicTarget(currentSession.target, 'exported'),
|
|
media: _publicMedia(currentSession.media),
|
|
loop: _clone(currentSession.loop),
|
|
route: _clone(currentSession.route),
|
|
};
|
|
_recordLifecycle(name, safePayload);
|
|
if (typeof capabilities.emitEvent === 'function') capabilities.emitEvent('playback', name, safePayload);
|
|
}
|
|
|
|
function _finalizeStoppedSession() {
|
|
if (!currentSession || currentSession.sessionId === 'playback-idle') return;
|
|
const summary = {
|
|
sessionId: currentSession.sessionId,
|
|
state: currentSession.state,
|
|
targetId: currentSession.target && currentSession.target.targetId || null,
|
|
stoppedAt: currentSession.stoppedAt || _now(),
|
|
recentOutcomes: currentSession.history.recentOutcomes.slice(-MAX_STOPPED_SESSION_ITEMS),
|
|
lifecycleEvents: currentSession.history.lifecycleEvents.slice(-MAX_STOPPED_SESSION_ITEMS),
|
|
droppedCounts: { ...currentSession.history.droppedCounts },
|
|
};
|
|
stoppedSessions.push(summary);
|
|
while (stoppedSessions.length > MAX_STOPPED_SESSIONS) stoppedSessions.shift();
|
|
}
|
|
|
|
function _setState(state, details = {}) {
|
|
const now = _now();
|
|
currentSession.state = state;
|
|
currentSession.transport.state = state;
|
|
currentSession.transport.isSeeking = state === 'seeking';
|
|
// 'degraded' is a route change (e.g. desktop -> browser-media fallback),
|
|
// not a transport change, so playback can continue through it — preserve
|
|
// the current isPlaying unless a caller passes an explicit value.
|
|
currentSession.transport.isPlaying = typeof details.isPlaying === 'boolean'
|
|
? details.isPlaying
|
|
: (state === 'playing' ? true
|
|
: (state === 'degraded' ? currentSession.transport.isPlaying : false));
|
|
currentSession.transport.readiness = details.readiness || (state === 'loading' ? 'loading' : (state === 'failed' ? 'failed' : (state === 'unavailable' ? 'unavailable' : (state === 'idle' ? 'idle' : 'ready'))));
|
|
currentSession.transport.requesterId = _requesterId(details.requesterId || currentSession.transport.requesterId, 'unknown');
|
|
currentSession.transport.priority = details.priority || currentSession.transport.priority || 'normal';
|
|
currentSession.transport.reason = _boundedReason(details.reason);
|
|
currentSession.transport.updatedAt = now;
|
|
currentSession.media.readiness = currentSession.transport.readiness;
|
|
currentSession.updatedAt = now;
|
|
if (state === 'stopped' || state === 'ended') currentSession.stoppedAt = now;
|
|
_touch();
|
|
}
|
|
|
|
function _hasActiveSession() {
|
|
return !!(currentSession && currentSession.target && !TERMINAL_STATES.has(currentSession.state));
|
|
}
|
|
|
|
function _targetMatches(args = {}) {
|
|
const source = _plainObject(args);
|
|
if (source.sessionId && source.sessionId !== currentSession.sessionId) return false;
|
|
if (source.targetId && currentSession.target && source.targetId !== currentSession.target.targetId) return false;
|
|
return true;
|
|
}
|
|
|
|
function _normalizeParticipant(source, role) {
|
|
const item = _plainObject(source);
|
|
const id = _requesterId(item.requesterId || item.observerId || item.participantId || item.id || item.source, role === 'observer' ? 'observer.unknown' : 'requester.unknown');
|
|
return {
|
|
id,
|
|
requesterId: role === 'requester' ? id : undefined,
|
|
observerId: role === 'observer' ? id : undefined,
|
|
role,
|
|
kind: _boundedReason(item.kind || (id.startsWith('core.') ? 'core' : 'plugin')).slice(0, 40),
|
|
priority: item.priority === 'user' ? 'user' : 'normal',
|
|
authorization: _boundedReason(item.authorization || 'none').slice(0, 40),
|
|
status: _boundedReason(item.status || 'available').slice(0, 40),
|
|
observes: Array.isArray(item.observes) ? item.observes.map(_eventName).slice(0, 30) : [],
|
|
lastSeenAt: _now(),
|
|
lastActionAt: item.lastActionAt || '',
|
|
};
|
|
}
|
|
|
|
function _rememberParticipant(map, normalized) {
|
|
const previous = map.get(normalized.id) || {};
|
|
// Re-insert so an updated participant counts as most-recently-seen, then
|
|
// evict the oldest beyond the cap to bound memory and snapshot work.
|
|
map.delete(normalized.id);
|
|
map.set(normalized.id, { ...previous, ...normalized, firstSeenAt: previous.firstSeenAt || normalized.lastSeenAt });
|
|
while (map.size > MAX_PARTICIPANTS) map.delete(map.keys().next().value);
|
|
return map.get(normalized.id);
|
|
}
|
|
|
|
function registerRequester(requester) {
|
|
const result = _rememberParticipant(requesters, _normalizeParticipant(requester, 'requester'));
|
|
_contributeDiagnostics();
|
|
return result;
|
|
}
|
|
|
|
function registerObserver(observer) {
|
|
const result = _rememberParticipant(observers, _normalizeParticipant(observer, 'observer'));
|
|
_contributeDiagnostics();
|
|
return result;
|
|
}
|
|
|
|
function _participants() {
|
|
// Take the most recent share from each role (the maps are themselves
|
|
// capped at registration) so the snapshot stays bounded and fair across
|
|
// requesters/observers before snapshot()'s JSON.stringify byte check —
|
|
// the budget-trim loops only shrink history, not participants.
|
|
const perRole = Math.floor(MAX_SAFE_ARRAY / 2);
|
|
const recent = values => Array.from(values).slice(-perRole);
|
|
return [...recent(requesters.values()), ...recent(observers.values())].map(item => {
|
|
const copy = { ...item };
|
|
delete copy.id;
|
|
Object.keys(copy).forEach(key => copy[key] === undefined && delete copy[key]);
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
function _publicTarget(target, exportMode = 'exported') {
|
|
if (!target) return null;
|
|
const out = {
|
|
targetId: target.targetId,
|
|
settingsKey: target.settingsKey,
|
|
sourceKind: target.sourceKind,
|
|
arrangementRef: target.arrangementRef,
|
|
format: target.format,
|
|
requestedBy: target.requestedBy,
|
|
};
|
|
if (exportMode === 'local-inspector' && target.localDisplay) out.localDisplay = _safeLocalDisplay(target.localDisplay);
|
|
return out;
|
|
}
|
|
|
|
function _publicMedia(media) {
|
|
const source = _plainObject(media);
|
|
return {
|
|
targetId: source.targetId || null,
|
|
currentTime: _number(source.currentTime, null),
|
|
duration: _number(source.duration, null),
|
|
playbackRate: _number(source.playbackRate, null),
|
|
chartTime: _number(source.chartTime, null),
|
|
mediaTime: _number(source.mediaTime, null),
|
|
timeUncertainty: _boundedReason(source.timeUncertainty || 'unknown').slice(0, 40),
|
|
readiness: _boundedReason(source.readiness || currentSession.transport.readiness || 'unknown').slice(0, 40),
|
|
route: _clone(currentSession.route),
|
|
loop: _clone(currentSession.loop),
|
|
};
|
|
}
|
|
|
|
function _bridgeSummary(entry) {
|
|
return {
|
|
bridgeId: entry.bridgeId,
|
|
legacySurface: entry.legacySurface,
|
|
source: entry.source,
|
|
hitCount: Math.min(Number(entry.hitCount || 0), 9999),
|
|
lastHitAt: entry.lastHitAt || null,
|
|
status: entry.status || 'active',
|
|
reason: _boundedReason(entry.reason),
|
|
};
|
|
}
|
|
|
|
function recordBridgeHit(bridge) {
|
|
const source = _plainObject(bridge);
|
|
const bridgeId = _requesterId(source.bridgeId || source.id || `playback.${source.legacySurface || 'legacy'}`, 'playback.legacy');
|
|
const previous = bridges.get(bridgeId) || { hitCount: 0 };
|
|
const entry = {
|
|
bridgeId,
|
|
legacySurface: _boundedReason(source.legacySurface || source.surface || 'unknown').slice(0, 80),
|
|
source: _requesterId(source.source || source.requesterId || 'legacy-runtime'),
|
|
hitCount: Math.min(Number(previous.hitCount || 0) + 1, 9999),
|
|
lastHitAt: source.lastHitAt || _now(),
|
|
status: _boundedReason(source.status || 'active').slice(0, 40),
|
|
reason: _boundedReason(source.reason || 'Legacy runtime surface used'),
|
|
};
|
|
bridges.set(bridgeId, entry);
|
|
while (bridges.size > MAX_BRIDGES) bridges.delete(bridges.keys().next().value);
|
|
if (typeof capabilities.recordLegacyHit === 'function') {
|
|
capabilities.recordLegacyHit({
|
|
capability: 'playback',
|
|
legacySurface: entry.legacySurface,
|
|
source: entry.source,
|
|
reason: entry.reason,
|
|
shimId: entry.bridgeId,
|
|
});
|
|
}
|
|
_emitPlayback('bridge-hit', { bridge: _bridgeSummary(entry) });
|
|
_contributeDiagnostics();
|
|
return _bridgeSummary(entry);
|
|
}
|
|
|
|
function _snapshotTransport() {
|
|
if (!transportAdapter || typeof transportAdapter.inspect !== 'function') return null;
|
|
try { return transportAdapter.inspect(); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function _mergeTransportSnapshot(snapshot) {
|
|
const source = _plainObject(snapshot);
|
|
if (!Object.keys(source).length) return;
|
|
const currentTime = _number(source.currentTime, _number(source.mediaTime, currentSession.media.currentTime));
|
|
currentSession.media.currentTime = currentTime;
|
|
currentSession.media.mediaTime = _number(source.mediaTime, currentTime);
|
|
currentSession.media.chartTime = _number(source.chartTime, currentTime);
|
|
currentSession.media.duration = _number(source.duration, currentSession.media.duration);
|
|
currentSession.media.playbackRate = _number(source.playbackRate, currentSession.media.playbackRate);
|
|
currentSession.media.timeUncertainty = _boundedReason(source.timeUncertainty || currentSession.media.timeUncertainty || 'none').slice(0, 40);
|
|
if (source.route || source.routeKind || source.routeState) _updateRoute(source.route || source);
|
|
if (source.loop || source.loopA != null || source.loopB != null) _updateLoopFromSnapshot(source.loop || source);
|
|
if (source.isPlaying === true) currentSession.transport.isPlaying = true;
|
|
if (source.isPlaying === false) currentSession.transport.isPlaying = false;
|
|
}
|
|
|
|
function _updateRoute(route) {
|
|
const source = _plainObject(route);
|
|
const routeKind = _boundedReason(source.routeKind || source.kind || (source.juceMode ? 'desktop-native' : 'browser-media')).slice(0, 40) || 'unknown';
|
|
currentSession.route = {
|
|
routeId: _safeInternalId(source.routeId, 'route', `route-${_hash(routeKind)}`),
|
|
routeKind,
|
|
state: _boundedReason(source.state || source.routeState || source.availability || 'active').slice(0, 40),
|
|
preservedTime: source.preservedTime == null ? null : !!source.preservedTime,
|
|
safeReason: _boundedReason(source.safeReason || source.reason),
|
|
lastChangedAt: source.lastChangedAt || _now(),
|
|
};
|
|
currentSession.media.route = _clone(currentSession.route);
|
|
}
|
|
|
|
function _updateLoopFromSnapshot(loop) {
|
|
const source = _plainObject(loop);
|
|
const start = _number(source.startTime, _number(source.loopA, null));
|
|
const end = _number(source.endTime, _number(source.loopB, null));
|
|
if (start == null || end == null) {
|
|
currentSession.loop = _inactiveLoop(source.state || 'inactive');
|
|
} else {
|
|
currentSession.loop = {
|
|
loopId: source.loopId || `loop-${_hash(`${currentSession.sessionId}:${start}:${end}`)}`,
|
|
sessionId: currentSession.sessionId,
|
|
startTime: start,
|
|
endTime: end,
|
|
enabled: source.enabled !== false,
|
|
requesterId: _requesterId(source.requesterId || currentSession.transport.requesterId, 'unknown'),
|
|
state: _boundedReason(source.state || 'active').slice(0, 40),
|
|
lastRestartAt: source.lastRestartAt || '',
|
|
reason: _boundedReason(source.reason),
|
|
};
|
|
}
|
|
currentSession.media.loop = _clone(currentSession.loop);
|
|
}
|
|
|
|
function snapshot(options = {}) {
|
|
_mergeTransportSnapshot(_snapshotTransport());
|
|
const exportMode = options.exportMode === 'local-inspector' || options.local === true ? 'local-inspector' : 'exported';
|
|
const data = {
|
|
schema: SCHEMA,
|
|
domain: 'playback',
|
|
generatedAt: _now(),
|
|
exportMode,
|
|
state: {
|
|
sessionId: currentSession.sessionId,
|
|
state: currentSession.state,
|
|
target: _publicTarget(currentSession.target, exportMode),
|
|
transport: _clone(currentSession.transport),
|
|
media: _publicMedia(currentSession.media),
|
|
route: _clone(currentSession.route),
|
|
loop: _clone(currentSession.loop),
|
|
},
|
|
participants: _participants(),
|
|
bridges: Array.from(bridges.values()).map(_bridgeSummary),
|
|
history: {
|
|
current: _clone(currentSession.history),
|
|
stoppedSessions: stoppedSessions.map(item => _clone(item)),
|
|
},
|
|
redaction: { targetIdentity: exportMode === 'exported' ? 'pseudonymous' : 'local-display-allowed', droppedCounts: _clone(currentSession.history.droppedCounts) },
|
|
};
|
|
let encoded = JSON.stringify(data);
|
|
while (encoded.length > MAX_SNAPSHOT_BYTES && data.history.stoppedSessions.length) {
|
|
data.history.stoppedSessions.shift();
|
|
data.redaction.trimmedForBudget = true;
|
|
encoded = JSON.stringify(data);
|
|
}
|
|
while (encoded.length > MAX_SNAPSHOT_BYTES && data.history.current.recentOutcomes.length) {
|
|
data.history.current.recentOutcomes.shift();
|
|
data.history.current.droppedCounts.outcomes += 1;
|
|
data.redaction.trimmedForBudget = true;
|
|
encoded = JSON.stringify(data);
|
|
}
|
|
while (encoded.length > MAX_SNAPSHOT_BYTES && data.history.current.lifecycleEvents.length) {
|
|
data.history.current.lifecycleEvents.shift();
|
|
data.history.current.droppedCounts.events += 1;
|
|
data.redaction.trimmedForBudget = true;
|
|
encoded = JSON.stringify(data);
|
|
}
|
|
// Budget trimming above bumps history.current.droppedCounts; mirror the
|
|
// final tally into redaction.droppedCounts so the summary matches the
|
|
// history actually returned (it was cloned pre-trim).
|
|
data.redaction.droppedCounts = _clone(data.history.current.droppedCounts);
|
|
return _safeValue(data, 0, exportMode);
|
|
}
|
|
|
|
function _flushDiagnostics() {
|
|
const diagnostics = window.feedBack && window.feedBack.diagnostics;
|
|
if (diagnostics && typeof diagnostics.contribute === 'function') {
|
|
try { diagnostics.contribute('playback', snapshot({ exportMode: 'exported' })); }
|
|
catch (_) { /* diagnostics must never break playback */ }
|
|
}
|
|
try { window.dispatchEvent(new CustomEvent('feedBack:playback:changed', { detail: { timestamp: _now() } })); }
|
|
catch (_) { /* support UI refresh is best effort */ }
|
|
}
|
|
|
|
// Coalesce contributions to once per microtask: a single command often
|
|
// touches state and records an outcome (each calls _touch -> here), and the
|
|
// snapshot()/UI-refresh work should run once for that logical action, not
|
|
// per mutation. Still flushes within the same tick, so freshness is kept.
|
|
let _contributeScheduled = false;
|
|
function _contributeDiagnostics() {
|
|
if (_contributeScheduled) return;
|
|
_contributeScheduled = true;
|
|
Promise.resolve().then(() => {
|
|
_contributeScheduled = false;
|
|
_flushDiagnostics();
|
|
});
|
|
}
|
|
|
|
function _handled(command, status, payload = {}) {
|
|
return { outcome: 'handled', status: status || 'handled', payload };
|
|
}
|
|
|
|
function _outcome(command, outcome, status, reason, payload = {}) {
|
|
const normalized = OUTCOMES.has(outcome) ? outcome : 'failed';
|
|
_recordOutcome(command, normalized, { status, reason, ...payload });
|
|
return { outcome: normalized, status: status || normalized, reason: _boundedReason(reason), payload };
|
|
}
|
|
|
|
// dispatch() runs command handlers concurrently with no per-capability
|
|
// lock, so any handler that awaits the transport adapter must re-check that
|
|
// the session it started with is still current before mutating state — a
|
|
// newer start/stop may have replaced it mid-await.
|
|
function _supersededOutcome(command, session, requesterId, extra = {}) {
|
|
return _outcome(command, 'stale', 'stale', `Playback session was superseded during ${command}`, { requesterId, sessionId: session.sessionId, ...extra });
|
|
}
|
|
|
|
function _commandArgs(ctx) {
|
|
const payload = { ..._plainObject(ctx && ctx.payload) };
|
|
if (ctx && ctx.requester) payload.requesterId = ctx.requester;
|
|
return payload;
|
|
}
|
|
|
|
function _inspect(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
// Don't force kind here — _commandArgs always injects the authenticated
|
|
// requesterId, so hard-coding 'plugin' misclassifies core.* callers.
|
|
// Omit kind and let _normalizeParticipant derive it from the id.
|
|
registerRequester({ requesterId: args.requesterId || 'inspect', authorization: 'none' });
|
|
_mergeTransportSnapshot(_snapshotTransport());
|
|
_recordOutcome('inspect', 'handled', { status: currentSession.state, requesterId: args.requesterId || ctx.requester });
|
|
return _handled('inspect', currentSession.state === 'idle' ? 'idle' : currentSession.transport.readiness, snapshot({ exportMode: args.includeLocalDisplay ? 'local-inspector' : 'exported' }));
|
|
}
|
|
|
|
async function _start(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'core.player.controls');
|
|
// Don't take kind from the raw payload: a control command must not let
|
|
// a plugin label itself 'core' in diagnostics (anti-spoofing — payload
|
|
// identity is for the explicit register-requester command only). Omit
|
|
// kind so _normalizeParticipant derives it from the authenticated
|
|
// requesterId ('core.*' -> 'core', otherwise 'plugin').
|
|
registerRequester({ requesterId, priority: _priority(args), authorization: args.authorization || 'none', lastActionAt: _now() });
|
|
if (!args.target || typeof args.target !== 'object') {
|
|
// A missing target is a malformed request, not an unavailable route;
|
|
// return the no-target outcome without emitting an 'unavailable'
|
|
// lifecycle event that would mislead observers/diagnostics.
|
|
return _outcome('start', 'no-target', 'no-target', 'No playback target supplied', { requesterId });
|
|
}
|
|
// Any terminal state (idle/ended/stopped/unavailable/failed) means no
|
|
// active session, so starting again is fresh audible playback and needs
|
|
// a user action — not just idle/stopped/ended.
|
|
const freshStart = !currentSession.target || TERMINAL_STATES.has(currentSession.state);
|
|
const target = _normalizeTarget(args.target, { requesterId, arrangement: args.arrangement });
|
|
// A fresh start OR switching to a different target is audible playback
|
|
// initiation and requires an explicit user action; plugins may only
|
|
// drive an already-active session for its current target without one.
|
|
const targetChange = !freshStart && currentSession.target && currentSession.target.targetId !== target.targetId;
|
|
if ((freshStart || targetChange) && args.authorization !== 'user-action') {
|
|
return _outcome('start', 'user-action-required', 'user-action-required', 'Fresh audible playback or switching target requires explicit user action', { requesterId });
|
|
}
|
|
if (currentSession.target && ACTIVE_STATES.has(currentSession.state)) {
|
|
_emitPlayback('superseded', { requesterId, previousSessionId: currentSession.sessionId });
|
|
_recordOutcome('start', 'stale', { status: 'superseded', requesterId, sessionId: currentSession.sessionId });
|
|
_finalizeStoppedSession();
|
|
}
|
|
commandSequence += 1;
|
|
if (_priority(args) === 'user') lastUserAction = { command: 'start', sequence: commandSequence, requesterId, createdAt: _now() };
|
|
currentSession = _newSession(target, { ...args, requesterId });
|
|
const session = currentSession;
|
|
_emitPlayback('requested', { requesterId, command: 'start' });
|
|
_emitPlayback('loading', { requesterId });
|
|
if (transportAdapter && typeof transportAdapter.start === 'function') {
|
|
try {
|
|
const result = await _driveAdapter(() => transportAdapter.start({ ...args, target: args.target, normalizedTarget: target, requesterId }));
|
|
if (currentSession !== session) return _supersededOutcome('start', session, requesterId);
|
|
_mergeTransportSnapshot(result);
|
|
} catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('start', session, requesterId);
|
|
_setState('failed', { requesterId, reason: err && err.message ? err.message : String(err) });
|
|
_emitPlayback('failed', { requesterId, reason: currentSession.transport.reason });
|
|
return _outcome('start', 'failed', 'failed', currentSession.transport.reason, { requesterId });
|
|
}
|
|
} else {
|
|
// No transport adapter is wired up, so nothing can actually start.
|
|
// Report the route as unavailable instead of falsely transitioning
|
|
// to ready/playing and emitting a success lifecycle event.
|
|
const reason = 'No playback transport adapter is registered';
|
|
_setState('unavailable', { requesterId, readiness: 'unavailable', reason });
|
|
_emitPlayback('unavailable', { requesterId, reason });
|
|
return _outcome('start', 'unavailable', 'unavailable', reason, { requesterId });
|
|
}
|
|
const nextState = currentSession.state === 'loading'
|
|
? (currentSession.transport.isPlaying ? 'playing' : 'ready')
|
|
: currentSession.state;
|
|
_setState(nextState, { requesterId, readiness: 'ready' });
|
|
_recordOutcome('start', 'handled', { status: currentSession.state, requesterId });
|
|
_emitPlayback(nextState === 'playing' ? 'started' : 'ready', { requesterId });
|
|
return _handled('start', currentSession.state, snapshot({ exportMode: 'exported' }));
|
|
}
|
|
|
|
function _denyIfStale(command, args) {
|
|
if (!_targetMatches(args)) return _outcome(command, 'stale', 'stale', 'Command targeted a stale playback session', { requesterId: args.requesterId, sessionId: _safeInternalId(args.sessionId, 'playback', currentSession.sessionId) });
|
|
return null;
|
|
}
|
|
|
|
function _denyIfNoTarget(command, args) {
|
|
if (!_hasActiveSession() && command !== 'stop') return _outcome(command, 'no-target', 'no-target', 'No active playback session', { requesterId: args.requesterId });
|
|
if (!currentSession.target && command === 'stop') return _outcome(command, 'no-target', 'no-target', 'No active playback session', { requesterId: args.requesterId });
|
|
return null;
|
|
}
|
|
|
|
function _userPriorityBlocks(command, args) {
|
|
if (_priority(args) === 'user') return null;
|
|
if (!lastUserAction) return null;
|
|
if (lastUserAction.command === 'pause' && command === 'resume') return _outcome(command, 'denied', 'denied', 'User-priority pause overrides normal resume', { requesterId: args.requesterId });
|
|
if (lastUserAction.command === 'stop' && command !== 'inspect') return _outcome(command, 'denied', 'denied', 'User-priority stop overrides normal playback request', { requesterId: args.requesterId });
|
|
return null;
|
|
}
|
|
|
|
async function _pause(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('pause', args); if (stale) return stale;
|
|
const missing = _denyIfNoTarget('pause', args); if (missing) return missing;
|
|
commandSequence += 1;
|
|
if (_priority(args) === 'user') lastUserAction = { command: 'pause', sequence: commandSequence, requesterId, createdAt: _now() };
|
|
// A stopped (terminal) session is already rejected as 'no-target' by
|
|
// _denyIfNoTarget above, so no separate 'already stopped' branch here.
|
|
const session = currentSession;
|
|
try { if (transportAdapter && typeof transportAdapter.pause === 'function') await _driveAdapter(() => transportAdapter.pause({ requesterId })); }
|
|
catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('pause', session, requesterId);
|
|
return _outcome('pause', 'failed', 'failed', err && err.message ? err.message : String(err), { requesterId });
|
|
}
|
|
if (currentSession !== session) return _supersededOutcome('pause', session, requesterId);
|
|
_setState('paused', { requesterId, priority: _priority(args) });
|
|
_recordOutcome('pause', 'handled', { status: 'paused', requesterId });
|
|
_emitPlayback('paused', { requesterId });
|
|
return _handled('pause', 'paused', snapshot({ exportMode: 'exported' }));
|
|
}
|
|
|
|
async function _resume(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('resume', args); if (stale) return stale;
|
|
const missing = _denyIfNoTarget('resume', args); if (missing) return missing;
|
|
const blocked = _userPriorityBlocks('resume', { ...args, requesterId }); if (blocked) return blocked;
|
|
// A user-priority resume is itself a user action that lifts a prior
|
|
// user-pause override — record it so it stops blocking later
|
|
// normal-priority resumes (the block keys off lastUserAction.command).
|
|
if (_priority(args) === 'user') { commandSequence += 1; lastUserAction = { command: 'resume', sequence: commandSequence, requesterId, createdAt: _now() }; }
|
|
const session = currentSession;
|
|
try {
|
|
if (transportAdapter && typeof transportAdapter.resume === 'function') {
|
|
const result = await _driveAdapter(() => transportAdapter.resume({ requesterId }));
|
|
if (currentSession !== session) return _supersededOutcome('resume', session, requesterId);
|
|
if (result && result.unavailable) return _outcome('resume', 'unavailable', 'unavailable', result.reason || 'Media route cannot resume', { requesterId });
|
|
}
|
|
} catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('resume', session, requesterId);
|
|
return _outcome('resume', 'failed', 'failed', err && err.message ? err.message : String(err), { requesterId });
|
|
}
|
|
if (currentSession !== session) return _supersededOutcome('resume', session, requesterId);
|
|
_setState('playing', { requesterId, priority: _priority(args) });
|
|
_recordOutcome('resume', 'handled', { status: 'playing', requesterId });
|
|
// Emit only 'resumed' (not 'started'): observers must be able to tell a
|
|
// resume from a fresh start, and a double signal can fire startup logic
|
|
// twice. Fresh playback emits 'started' from _start.
|
|
_emitPlayback('resumed', { requesterId });
|
|
return _handled('resume', 'playing', snapshot({ exportMode: 'exported' }));
|
|
}
|
|
|
|
async function _stop(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('stop', args); if (stale) return stale;
|
|
if (!currentSession.target) return _outcome('stop', 'no-target', 'no-target', 'No active playback session', { requesterId });
|
|
// Idempotent: an already-stopped session must not re-hit the transport
|
|
// adapter (whose stop() re-emits song:stop) or re-archive the session.
|
|
if (currentSession.state === 'stopped') return _outcome('stop', 'stopped', 'stopped', 'Playback is already stopped', { requesterId });
|
|
commandSequence += 1;
|
|
if (_priority(args) === 'user') lastUserAction = { command: 'stop', sequence: commandSequence, requesterId, createdAt: _now() };
|
|
let failedReason = '';
|
|
const session = currentSession;
|
|
try { if (transportAdapter && typeof transportAdapter.stop === 'function') await _driveAdapter(() => transportAdapter.stop({ requesterId, reason: args.reason })); }
|
|
catch (err) { failedReason = err && err.message ? err.message : String(err); }
|
|
if (currentSession !== session) return _supersededOutcome('stop', session, requesterId);
|
|
// One bounded reason, used verbatim across state, event, outcome, and
|
|
// the returned value (failure reason takes precedence, else the
|
|
// caller's). Bounding up front keeps all four literally identical.
|
|
const reason = _boundedReason(failedReason || args.reason);
|
|
_setState('stopped', { requesterId, priority: _priority(args), reason });
|
|
_recordOutcome('stop', failedReason ? 'failed' : 'stopped', { status: 'stopped', requesterId, reason });
|
|
_emitPlayback('stopped', { requesterId, reason });
|
|
_finalizeStoppedSession();
|
|
return { outcome: failedReason ? 'failed' : 'stopped', status: 'stopped', reason, payload: snapshot({ exportMode: 'exported' }) };
|
|
}
|
|
|
|
async function _seek(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('seek', args); if (stale) return stale;
|
|
const missing = _denyIfNoTarget('seek', args); if (missing) return missing;
|
|
const requestedTime = _number(args.time, null);
|
|
if (requestedTime == null) return _outcome('seek', 'failed', 'failed', 'Seek target must be finite', { requesterId });
|
|
const fromTime = currentSession.media.currentTime;
|
|
const wasPlaying = currentSession.transport.isPlaying;
|
|
if (!transportAdapter || typeof transportAdapter.seek !== 'function') return _outcome('seek', 'unsupported-command', 'unsupported-command', 'Playback route does not support seek', { requesterId, requestedTime, fromTime });
|
|
const session = currentSession;
|
|
_setState('seeking', { requesterId, priority: _priority(args), reason: args.reason });
|
|
_emitPlayback('seeking', { requesterId, requestedTime, fromTime, reason: args.reason });
|
|
let result = null;
|
|
try {
|
|
result = await _driveAdapter(() => transportAdapter.seek({ time: requestedTime, requesterId, reason: args.reason }));
|
|
} catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('seek', session, requesterId, { requestedTime, fromTime });
|
|
_setState(wasPlaying ? 'playing' : 'paused', { requesterId, reason: 'seek failed' });
|
|
return _outcome('seek', 'failed', 'failed', err && err.message ? err.message : String(err), { requesterId, requestedTime, fromTime });
|
|
}
|
|
if (currentSession !== session) return _supersededOutcome('seek', session, requesterId, { requestedTime, fromTime });
|
|
const completed = result && result.completed !== false;
|
|
const landedTime = _number(result && result.to, _number(result && result.landedTime, null));
|
|
const actualFrom = _number(result && result.from, fromTime);
|
|
if (completed && landedTime == null) {
|
|
_setState(wasPlaying ? 'playing' : 'paused', { requesterId, reason: 'malformed seek result' });
|
|
return _outcome('seek', 'failed', 'failed', 'Playback route returned a malformed seek result', { requesterId, requestedTime, fromTime: actualFrom });
|
|
}
|
|
if (!completed) {
|
|
_setState(wasPlaying ? 'playing' : 'paused', { requesterId, reason: 'seek cancelled' });
|
|
_emitPlayback('seeked', { requesterId, requestedTime, fromTime: actualFrom, landedTime, status: 'cancelled' });
|
|
return _outcome('seek', 'cancelled', 'cancelled', 'Seek cancelled before completion', { requesterId, requestedTime, fromTime: actualFrom, landedTime });
|
|
}
|
|
currentSession.media.currentTime = landedTime;
|
|
currentSession.media.mediaTime = landedTime;
|
|
currentSession.media.chartTime = landedTime;
|
|
currentSession.media.timeUncertainty = 'none';
|
|
const delta = landedTime == null ? 0 : Math.abs(landedTime - requestedTime);
|
|
const status = delta <= 0.05 ? 'completed' : (landedTime === 0 || (currentSession.media.duration != null && Math.abs(landedTime - currentSession.media.duration) <= 0.05) ? 'clamped' : 'rolled-back');
|
|
_setState(wasPlaying ? 'playing' : 'paused', { requesterId });
|
|
_recordOutcome('seek', 'handled', { status, requesterId, requestedTime, fromTime: actualFrom, landedTime, reason: args.reason });
|
|
_emitPlayback('seeked', { requesterId, requestedTime, fromTime: actualFrom, landedTime, status });
|
|
return _handled('seek', status, { requestedTime, fromTime: actualFrom, landedTime, status, snapshot: snapshot({ exportMode: 'exported' }) });
|
|
}
|
|
|
|
async function _setLoop(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('set-loop', args); if (stale) return stale;
|
|
const missing = _denyIfNoTarget('set-loop', args); if (missing) return missing;
|
|
const startTime = _number(args.startTime, null);
|
|
const endTime = _number(args.endTime, null);
|
|
if (startTime == null || endTime == null || endTime <= startTime) {
|
|
_emitPlayback('loop-rejected', { requesterId, reason: 'invalid loop boundaries' });
|
|
return _outcome('set-loop', 'failed', 'rejected', 'Invalid loop boundaries', { requesterId });
|
|
}
|
|
let ok = true;
|
|
const session = currentSession;
|
|
try { if (transportAdapter && typeof transportAdapter.setLoop === 'function') ok = await _driveAdapter(() => transportAdapter.setLoop({ startTime, endTime, requesterId })); }
|
|
catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('set-loop', session, requesterId);
|
|
_emitPlayback('loop-rejected', { requesterId, reason: err && err.message ? err.message : String(err) });
|
|
return _outcome('set-loop', 'failed', 'failed', err && err.message ? err.message : String(err), { requesterId });
|
|
}
|
|
if (currentSession !== session) return _supersededOutcome('set-loop', session, requesterId);
|
|
if (!ok) {
|
|
_emitPlayback('loop-rejected', { requesterId, reason: 'loop-set seek did not land on target' });
|
|
return _outcome('set-loop', 'cancelled', 'cancelled', 'Loop-set seek did not land on target', { requesterId });
|
|
}
|
|
_updateLoopFromSnapshot({ startTime, endTime, requesterId, state: 'active', enabled: true });
|
|
_recordOutcome('set-loop', 'handled', { status: 'active', requesterId });
|
|
_emitPlayback('loop-set', { requesterId, loop: _clone(currentSession.loop) });
|
|
return _handled('set-loop', 'active', { loop: _clone(currentSession.loop) });
|
|
}
|
|
|
|
async function _clearLoop(ctx) {
|
|
const args = _commandArgs(ctx);
|
|
const requesterId = _requesterId(args.requesterId || ctx.requester || 'unknown');
|
|
const stale = _denyIfStale('clear-loop', args); if (stale) return stale;
|
|
const missing = _denyIfNoTarget('clear-loop', args); if (missing) return missing;
|
|
const session = currentSession;
|
|
try { if (transportAdapter && typeof transportAdapter.clearLoop === 'function') await _driveAdapter(() => transportAdapter.clearLoop({ requesterId })); }
|
|
catch (err) {
|
|
if (currentSession !== session) return _supersededOutcome('clear-loop', session, requesterId);
|
|
return _outcome('clear-loop', 'failed', 'failed', err && err.message ? err.message : String(err), { requesterId });
|
|
}
|
|
if (currentSession !== session) return _supersededOutcome('clear-loop', session, requesterId);
|
|
currentSession.loop = _inactiveLoop('cleared');
|
|
currentSession.media.loop = _clone(currentSession.loop);
|
|
_recordOutcome('clear-loop', 'handled', { status: 'cleared', requesterId });
|
|
_emitPlayback('loop-cleared', { requesterId });
|
|
return _handled('clear-loop', 'cleared', { loop: _clone(currentSession.loop) });
|
|
}
|
|
|
|
function _registerRequesterCommand(ctx) {
|
|
const args = _plainObject(ctx && ctx.payload);
|
|
const requester = registerRequester(args);
|
|
_recordOutcome('register-requester', 'handled', { status: 'registered', requesterId: requester.requesterId });
|
|
return _handled('register-requester', 'registered', { requester });
|
|
}
|
|
|
|
function _registerObserverCommand(ctx) {
|
|
const args = _plainObject(ctx && ctx.payload);
|
|
const observer = registerObserver(args);
|
|
_recordOutcome('register-observer', 'handled', { status: 'registered', requesterId: observer.observerId });
|
|
return _handled('register-observer', 'registered', { observer });
|
|
}
|
|
|
|
function registerTransportAdapter(adapter) {
|
|
transportAdapter = adapter && typeof adapter === 'object' ? adapter : null;
|
|
_mergeTransportSnapshot(_snapshotTransport());
|
|
_contributeDiagnostics();
|
|
return true;
|
|
}
|
|
|
|
function transportEvent(event, payload = {}) {
|
|
const name = _eventName(event);
|
|
const source = _plainObject(payload);
|
|
if (source.target) {
|
|
const target = _normalizeTarget(source.target, source);
|
|
if (!currentSession.target || source.newSession === true || currentSession.state === 'idle' || currentSession.state === 'stopped' || currentSession.state === 'ended') {
|
|
currentSession = _newSession(target, source);
|
|
} else {
|
|
currentSession.target = target;
|
|
}
|
|
}
|
|
const hasSnapshot = source.media || source.currentTime != null || source.duration != null || source.route || source.loop;
|
|
if (hasSnapshot && name !== 'seeked' && name !== 'seeking') _mergeTransportSnapshot(source.media || source);
|
|
if (name === 'loading') _setState('loading', { requesterId: source.requesterId, readiness: 'loading' });
|
|
else if (name === 'ready') _setState(currentSession.transport.isPlaying ? 'playing' : 'ready', { requesterId: source.requesterId, readiness: 'ready' });
|
|
else if (name === 'started' || name === 'resumed') _setState('playing', { requesterId: source.requesterId, readiness: 'ready' });
|
|
else if (name === 'paused') _setState('paused', { requesterId: source.requesterId, readiness: 'ready' });
|
|
else if (name === 'ended') _setState('ended', { requesterId: source.requesterId, readiness: 'ready' });
|
|
else if (name === 'stopped') { _setState('stopped', { requesterId: source.requesterId, reason: source.reason }); _finalizeStoppedSession(); }
|
|
else if (name === 'unavailable') _setState('unavailable', { requesterId: source.requesterId, readiness: 'unavailable', reason: source.reason });
|
|
else if (name === 'failed') _setState('failed', { requesterId: source.requesterId, readiness: 'failed', reason: source.reason });
|
|
else if (name === 'degraded') _setState('degraded', { requesterId: source.requesterId, readiness: 'ready', reason: source.reason });
|
|
else if (name === 'seeking') {
|
|
pendingSeekWasPlaying = _payloadIsPlaying(source, currentSession.transport.isPlaying);
|
|
if (hasSnapshot) _mergeTransportSnapshot(source.media || source);
|
|
_setState('seeking', { requesterId: source.requesterId, readiness: 'ready', reason: source.reason });
|
|
} else if (name === 'seeked') {
|
|
const wasPlaying = pendingSeekWasPlaying == null ? currentSession.transport.isPlaying : pendingSeekWasPlaying;
|
|
if (hasSnapshot) _mergeTransportSnapshot(source.media || source);
|
|
const shouldPlay = _payloadIsPlaying(source, wasPlaying);
|
|
pendingSeekWasPlaying = null;
|
|
_setState(shouldPlay ? 'playing' : 'paused', { requesterId: source.requesterId, readiness: 'ready', reason: source.reason });
|
|
}
|
|
else if (name === 'loop-restarted') {
|
|
if (currentSession.loop) currentSession.loop.lastRestartAt = _now();
|
|
} else if (name === 'loop-stale') {
|
|
if (currentSession.loop) currentSession.loop.state = 'stale';
|
|
}
|
|
if (name === 'route-changing') _updateRoute({ ...source, state: 'switching' });
|
|
if (name === 'route-changed') _updateRoute(source);
|
|
_emitPlayback(name, source);
|
|
// Record failure-shaped lifecycle events (failed/unavailable/degraded/
|
|
// stopped) under their own outcome rather than 'handled', so diagnostics
|
|
// history matches the actual transition.
|
|
_recordOutcome(name, OUTCOMES.has(name) ? name : 'handled', { status: currentSession.state, requesterId: source.requesterId, reason: source.reason });
|
|
return snapshot({ exportMode: 'exported' });
|
|
}
|
|
|
|
function recordRouteChange(route) {
|
|
const source = _plainObject(route);
|
|
_updateRoute(source);
|
|
const name = source.state === 'switching' || source.phase === 'changing' ? 'route-changing' : 'route-changed';
|
|
_emitPlayback(name, source);
|
|
if (source.state === 'degraded' || source.degraded) {
|
|
_setState('degraded', { requesterId: source.requesterId || 'core.player.route', reason: source.safeReason || source.reason });
|
|
_emitPlayback('degraded', source);
|
|
_recordOutcome('route-change', 'degraded', { status: 'degraded', requesterId: source.requesterId || 'core.player.route', reason: source.safeReason || source.reason });
|
|
} else {
|
|
_recordOutcome('route-change', 'handled', { status: source.state || 'active', requesterId: source.requesterId || 'core.player.route', reason: source.safeReason || source.reason });
|
|
}
|
|
return _clone(currentSession.route);
|
|
}
|
|
|
|
function _installLegacyEventBridge() {
|
|
if (!window.feedBack || typeof window.feedBack.on !== 'function') return;
|
|
const map = {
|
|
'song:loading': 'loading',
|
|
'song:loaded': 'ready',
|
|
'song:ready': 'ready',
|
|
'song:play': 'started',
|
|
'song:resume': 'resumed',
|
|
'song:pause': 'paused',
|
|
'song:seek': 'seeked',
|
|
'song:ended': 'ended',
|
|
'song:stop': 'stopped',
|
|
'loop:restart': 'loop-restarted',
|
|
};
|
|
for (const [legacy, playbackEvent] of Object.entries(map)) {
|
|
window.feedBack.on(legacy, event => {
|
|
// Skip echoes of our own command: while a command handler is
|
|
// driving the transport adapter, the song:* events it produces
|
|
// are not genuine legacy-surface usage and would duplicate the
|
|
// command's own playback:* events (and re-archive on stop).
|
|
if (commandAdapterDepth > 0) return;
|
|
const detail = _plainObject(event && event.detail);
|
|
recordBridgeHit({ bridgeId: 'playback.song-events', legacySurface: legacy, source: detail.requesterId || 'legacy-event-bus', reason: 'legacy song event observed' });
|
|
if (legacy === 'song:loading') {
|
|
transportEvent('loading', { target: { filename: detail.filename, arrangement: detail.arrangement, sourceKind: 'local' }, requesterId: 'core.player.controls', newSession: true });
|
|
return;
|
|
}
|
|
if (legacy === 'song:loaded') {
|
|
transportEvent('ready', { target: detail, media: { duration: detail.duration, readiness: 'ready' }, requesterId: 'core.highway' });
|
|
return;
|
|
}
|
|
if (legacy === 'song:seek') {
|
|
transportEvent('seeked', { fromTime: detail.from, landedTime: detail.to, currentTime: detail.to, mediaTime: detail.to, chartTime: detail.to, reason: detail.reason, requesterId: 'legacy-event-bus' });
|
|
return;
|
|
}
|
|
if (legacy === 'loop:restart') {
|
|
transportEvent('loop-restarted', { loopA: detail.loopA, loopB: detail.loopB, currentTime: detail.time, requesterId: 'core.loop' });
|
|
return;
|
|
}
|
|
transportEvent(playbackEvent, { ...detail, requesterId: detail.requesterId || 'legacy-event-bus' });
|
|
});
|
|
}
|
|
}
|
|
|
|
capabilities.registerOwner('playback', {
|
|
pluginId: OWNER_ID,
|
|
commands: COMMANDS,
|
|
events: EVENT_NAMES,
|
|
kind: 'command',
|
|
compatibility: 'shim-allowed',
|
|
ownership: 'exclusive-owner',
|
|
safety: 'safe',
|
|
description: 'Authoritative redaction-safe playback transport, timing, loop, route, requester, bridge, and diagnostics surface.',
|
|
handlers: {
|
|
inspect: _inspect,
|
|
start: _start,
|
|
pause: _pause,
|
|
resume: _resume,
|
|
stop: _stop,
|
|
seek: _seek,
|
|
'set-loop': _setLoop,
|
|
'clear-loop': _clearLoop,
|
|
'register-requester': _registerRequesterCommand,
|
|
'register-observer': _registerObserverCommand,
|
|
},
|
|
});
|
|
|
|
const api = {
|
|
version: 1,
|
|
registerTransportAdapter,
|
|
registerRequester,
|
|
registerObserver,
|
|
recordBridgeHit,
|
|
recordRouteChange,
|
|
transportEvent,
|
|
snapshot,
|
|
getDiagnostics: snapshot,
|
|
inspect: () => snapshot({ exportMode: 'exported' }),
|
|
};
|
|
|
|
window.feedBack.playback = api;
|
|
_installLegacyEventBridge();
|
|
_contributeDiagnostics();
|
|
})();
|