From 946528350cccb86f2a4c7f8895edf87587ad1fec Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 15:37:26 +0200 Subject: [PATCH] refactor(orbit + shortcuts): unify host/guest heartbeat, document shortcut contract (Phase I) (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/config/shortcutActions.ts | 39 +++++++++++++++++++++++++++- src/config/shortcutTypes.ts | 21 +++++++++++++++ src/hooks/useOrbitGuest.ts | 29 +++++---------------- src/hooks/useOrbitHost.ts | 23 ++++------------ src/hooks/useOrbitOutboxHeartbeat.ts | 38 +++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 41 deletions(-) create mode 100644 src/hooks/useOrbitOutboxHeartbeat.ts diff --git a/src/config/shortcutActions.ts b/src/config/shortcutActions.ts index c6b65127..668a7d6e 100644 --- a/src/config/shortcutActions.ts +++ b/src/config/shortcutActions.ts @@ -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 `. 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, diff --git a/src/config/shortcutTypes.ts b/src/config/shortcutTypes.ts index da6ebbfd..c5f9ed09 100644 --- a/src/config/shortcutTypes.ts +++ b/src/config/shortcutTypes.ts @@ -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 `. `command` overrides the + * CLI verb used for no-arg dispatch (defaults to the action id). */ cli?: { verb: string; description: string; command?: string }; }; diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index d6b8159f..2d5e1ca6 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -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); } diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 0f7c6aaf..24e4faf0 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -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); } diff --git a/src/hooks/useOrbitOutboxHeartbeat.ts b/src/hooks/useOrbitOutboxHeartbeat.ts new file mode 100644 index 00000000..23d76d91 --- /dev/null +++ b/src/hooks/useOrbitOutboxHeartbeat.ts @@ -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]); +}