refactor(orbit + shortcuts): unify host/guest heartbeat, document shortcut contract (Phase I) (#693)

* refactor(orbit): unify host/guest outbox heartbeat into a shared hook (Phase I)

The outbox-heartbeat effect was duplicated near-verbatim in useOrbitHost
and useOrbitGuest — same 10 s interval, same writeOrbitHeartbeat call,
same cleanup; the only difference is whose name owns the outbox
(OrbitState.host vs the active-server username).

Extract it into useOrbitOutboxHeartbeat(active, outboxPlaylistId,
sessionId, ownName). Host and guest each pass their own name source.
The push/pull state-tick logic stays untouched — that asymmetry is the
real host/guest difference, not duplication.

Behaviour-preserving: the owner name is now a reactive hook arg instead
of a getState() read inside the effect, so the heartbeat starts as soon
as the name is available rather than waiting for an unrelated dep to
change — a strict improvement, unreachable in practice since host name
and username are fixed per session.

* docs(shortcuts): document the shortcut-actions contract (Phase I)

Add a contract reference block to the shortcutActions barrel — the three
independent trigger surfaces (inApp / global / runInMiniWindow), the
surface-independent cli + run fields, the dispatch entry points — and
per-field doc comments on ShortcutActionMeta / ShortcutSlot /
ActionContext / CliContext in shortcutTypes.ts.

Pure documentation, no code change.
This commit is contained in:
Frank Stellmacher
2026-05-14 15:37:26 +02:00
committed by GitHub
parent 4b1dd3c29f
commit 946528350c
5 changed files with 109 additions and 41 deletions
+38 -1
View File
@@ -1,9 +1,46 @@
// Barrel for the shortcut-action subsystem. Split into:
// ─── Shortcut-action subsystem ───────────────────────────────────────────────
//
// Barrel + contract reference. Split into:
// shortcutTypes.ts — shared types
// shortcutActionRegistry.ts — SHORTCUT_ACTION_REGISTRY + action id types
// shortcutDispatch.ts — runtime + CLI dispatch
// shortcutBindings.ts — derived in-app / global binding tables
// Existing call sites import from this module unchanged.
//
// ── The contract ─────────────────────────────────────────────────────────────
//
// Every shortcut action is one entry in `SHORTCUT_ACTION_REGISTRY`, keyed by a
// stable id (`ShortcutAction`). Its `ShortcutActionMeta` declares which of three
// independent *trigger surfaces* the action is exposed on — an action may opt
// into any combination:
//
// • inApp? — bindable to a keyboard chord while the MAIN window is focused.
// Presence of the `inApp` slot makes the id a `KeyAction`; the
// slot's `defaultBinding` is the out-of-box chord (or null =
// unbound), `hidden` keeps it out of the Settings UI list.
// Surfaced via IN_APP_SHORTCUT_ACTIONS / DEFAULT_IN_APP_BINDINGS;
// matched at runtime by `shortcuts/runtime.ts`.
// • global? — registrable as an OS-LEVEL global hotkey (fires even when the
// app is unfocused). Presence makes the id a `GlobalAction`;
// surfaced via GLOBAL_SHORTCUT_ACTIONS / DEFAULT_GLOBAL_SHORTCUTS.
// `isGlobalShortcutActionId` is the runtime guard.
// • runInMiniWindow — whether the action may run when triggered FROM the
// mini-player window. `canRunShortcutActionInMiniWindow` gates
// cross-window `shortcut:run-action` events.
//
// Two extra, surface-independent fields:
// • cli? — exposes the action to `psysonic --player <verb>`. No-arg CLI
// verbs are auto-collected and dispatched by
// `executeCliPlayerCommand`; arg-carrying commands (play-id,
// seek-relative, set-volume, set-repeat, set-rating-current)
// are handled explicitly there.
// • run(ctx) — the handler. `ctx.previewPolicy` ('stop' | 'ignore') decides
// whether an active track-preview is interrupted: media keys
// pass 'ignore', explicit UI / in-app keys pass 'stop'.
//
// Dispatch entry points: `executeRuntimeAction` (any trigger surface) and
// `executeCliPlayerCommand` (CLI). `isShortcutAction` validates an arbitrary
// string against the registry.
export type {
TranslateLike,
+21
View File
@@ -1,22 +1,43 @@
// Shared types for the shortcut-action subsystem. The contract overview lives
// in the barrel, `shortcutActions.ts`.
export type TranslateLike = (key: string, options?: any) => string;
/** One bindable slot (in-app OR global). `defaultBinding` is the out-of-box
* chord, or `null` for "unbound by default". `hidden` keeps the action out of
* the Settings shortcut-list UI (still bindable / dispatchable). */
export type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean };
/** Passed to an action's `run`. `previewPolicy` decides whether an active
* track-preview is interrupted: 'stop' for explicit UI / in-app keys, 'ignore'
* for hardware media keys. */
export type ActionContext = {
navigate: (to: string, options?: any) => void;
previewPolicy: 'stop' | 'ignore';
};
/** Passed to `executeCliPlayerCommand` — the raw `cli:player-command` payload
* plus a navigate fn. */
export type CliContext = {
navigate: (to: string, options?: any) => void;
payload: any;
};
/** Registry entry for one shortcut action. `inApp` / `global` /
* `runInMiniWindow` are the three independent trigger surfaces; `cli` and
* `run` are surface-independent. See the contract block in `shortcutActions.ts`. */
export type ShortcutActionMeta = {
/** Localized display label for the Settings UI. */
getLabel: (t: TranslateLike) => string;
/** Present ⇒ bindable to a main-window keyboard chord (id becomes a `KeyAction`). */
inApp?: ShortcutSlot;
/** Present ⇒ registrable as an OS-level global hotkey (id becomes a `GlobalAction`). */
global?: ShortcutSlot;
/** Whether the action may run when triggered from the mini-player window. */
runInMiniWindow: boolean;
/** The handler. */
run: (ctx: ActionContext) => void;
/** Present ⇒ exposed to `psysonic --player <verb>`. `command` overrides the
* CLI verb used for no-arg dispatch (defaults to the action id). */
cli?: { verb: string; description: string; command?: string };
};
+7 -22
View File
@@ -4,12 +4,10 @@ import { useEffect, useRef } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import {
readOrbitState,
writeOrbitHeartbeat,
} from '../utils/orbit';
import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit';
import { readOrbitState } from '../utils/orbit';
import { estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
/**
* Orbit — guest-side tick hook.
@@ -29,7 +27,6 @@ import { pushOrbitEvent } from '../utils/orbitDiag';
*/
const STATE_READ_TICK_MS = 2_500;
const HEARTBEAT_TICK_MS = 10_000;
/**
* Host must be quiet (no state writes) for this long before we treat the
* session as dead and auto-leave. Well above any normal network blip —
@@ -44,6 +41,7 @@ export function useOrbitGuest(): void {
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const myName = useAuthStore(s => s.getActiveServer()?.username);
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
@@ -362,20 +360,7 @@ export function useOrbitGuest(): void {
};
}, [active, sessionPlaylistId]);
// ── Heartbeat ────────────────────────────────────────────────────────
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId) return;
const me = useAuthStore.getState().getActiveServer()?.username;
if (!me) return;
const outboxName = orbitOutboxPlaylistName(sessionId, me);
const beat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void beat();
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId]);
// Outbox heartbeat — shared with the host hook; the guest's outbox is keyed
// by its own active-server username.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, myName);
}
+5 -18
View File
@@ -5,7 +5,6 @@ import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import {
writeOrbitState,
writeOrbitHeartbeat,
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
@@ -13,7 +12,6 @@ import {
suggestionKey,
} from '../utils/orbit';
import {
orbitOutboxPlaylistName,
ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState,
type OrbitQueueItem,
@@ -21,6 +19,7 @@ import {
import { showToast } from '../utils/ui/toast';
import i18n from '../i18n';
import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
/**
* Orbit — host-side tick hook.
@@ -41,7 +40,6 @@ import { pushOrbitEvent } from '../utils/orbitDiag';
*/
const STATE_TICK_MS = 2_500;
const HEARTBEAT_TICK_MS = 10_000;
export function useOrbitHost(): void {
const role = useOrbitStore(s => s.role);
@@ -49,6 +47,7 @@ export function useOrbitHost(): void {
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const hostName = useOrbitStore(s => s.state?.host);
// Refs hold the last values we used to build the patch — cheap to
// recompute against, no need to subscribe to every playerStore tick.
@@ -248,19 +247,7 @@ export function useOrbitHost(): void {
};
}, [active, sessionPlaylistId]);
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId) return;
const server = useOrbitStore.getState().state?.host;
if (!server) return;
const outboxName = orbitOutboxPlaylistName(sessionId, server);
const pushHeartbeat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void pushHeartbeat();
const id = window.setInterval(() => { void pushHeartbeat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId]);
// Outbox heartbeat — shared with the guest hook; the host's outbox is keyed
// by its own `OrbitState.host` name.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, hostName);
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { writeOrbitHeartbeat } from '../utils/orbit';
import { orbitOutboxPlaylistName } from '../api/orbit';
const HEARTBEAT_TICK_MS = 10_000;
/**
* Shared Orbit outbox heartbeat — used by both the host and guest tick hooks.
*
* Refreshes the caller's own outbox playlist comment with a fresh timestamp
* every 10 s so the host's participant sweep sees the user as alive (and so
* the host's own outbox is refreshed symmetrically). Host and guest differ
* only in whose name owns the outbox: `ownName` is `OrbitState.host` for the
* host and the active server username for a guest.
*
* Best-effort — a transient Navidrome outage just skips a beat; the next
* interval tick retries.
*/
export function useOrbitOutboxHeartbeat(
active: boolean,
outboxPlaylistId: string | null,
sessionId: string | null,
ownName: string | null | undefined,
): void {
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId || !ownName) return;
const outboxName = orbitOutboxPlaylistName(sessionId, ownName);
const beat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void beat();
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId, ownName]);
}