refactor(decouple): orbit seam — invert orbit off the audio core

Add core seam store/orbitRuntime.ts: a registry (registerOrbitRuntime) exposing a
neutral session snapshot {role,phase,state} + an async bulkGuard, plus pure
derivations mirrored from the feature (isInOrbitSession, isOrbitPlaybackSyncActive,
estimateLivePosition). Default (unregistered) = neutral snapshot + bulkGuard allow
— bit-identical to today's no-session behavior, and a session can only start via
the topbar which loads the @/features/orbit barrel (→ registers) first.

features/orbit/utils/orbitBulkGuard registers the runtime at module init
(store-backed getSnapshot + the existing confirm-modal orbitBulkGuard as the gate).
The orbit feature keeps its own copies of the pure helpers for UI (incl. the
arg-form isOrbitPlaybackSyncActive(role,phase) used by two settings/player-bar
components), so nothing UI-facing changes.

Repointed the 9 audio-core sites (playbackRateStore, previewStore,
playbackReportSession, nextAction, resumeAction, playTrackAction,
queueMutationActions, playAlbum) from @/features/orbit to @/store/orbitRuntime;
state reads (useOrbitStore.getState().role/.state) become orbitSnapshot().
Migrated 6 audio-core test mocks to @/store/orbitRuntime via importOriginal-spread
(keeps registerOrbitRuntime callable). enqueueShareSearchPayload stays on the orbit
barrel (share util, not audio core) — its test mock unchanged.

Decouple Step 3 — last seam. The audio ENGINE is now free of @/features/* runtime
imports (only type-only edges + the non-engine composerBrowseSessionStore browse
store remain). Unblocks the playback-core move + utils/library→lib.

tsc 0, lint 0, full suite 319/2353 green.
NEEDS Frank's live-session QA before relying on it (host+guest bulk-replace modal,
guest catch-up, rate/preview/scrobble suppression).
This commit is contained in:
Psychotoxical
2026-06-30 14:19:22 +02:00
parent 42ef09f1e4
commit 651a2adba4
16 changed files with 110 additions and 22 deletions
+14 -2
View File
@@ -1,6 +1,7 @@
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useConfirmModalStore } from '@/store/confirmModalStore';
import i18n from '@/lib/i18n';
import { registerOrbitRuntime } from '@/store/orbitRuntime';
/**
* Ask the user before dropping many tracks into the shared Orbit queue.
@@ -9,8 +10,8 @@ import i18n from '@/lib/i18n';
* when the user accepted the confirm dialog. Returns `false` only when an
* active-Orbit user explicitly cancelled.
*
* Lives in its own module so `playerStore` can use it without pulling the
* full `utils/orbit.ts` (which itself imports `playerStore` — circular).
* The audio core reaches this (and the orbit session snapshot) through the
* neutral `@/store/orbitRuntime` seam, not by importing the orbit feature.
*/
export async function orbitBulkGuard(count: number): Promise<boolean> {
const role = useOrbitStore.getState().role;
@@ -24,3 +25,14 @@ export async function orbitBulkGuard(count: number): Promise<boolean> {
cancelLabel: i18n.t('orbit.bulkConfirmNo'),
});
}
// Install the orbit runtime into the core seam at module init. The
// @/features/orbit barrel re-exports this module, so the topbar's barrel import
// evaluates it at boot — before any Orbit session can start.
registerOrbitRuntime({
getSnapshot: () => {
const o = useOrbitStore.getState();
return { role: o.role, phase: o.phase, state: o.state };
},
bulkGuard: orbitBulkGuard,
});
+1 -1
View File
@@ -9,7 +9,7 @@ import {
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from './infiniteQueueState';
import { isInOrbitSession } from '@/features/orbit';
import { isInOrbitSession } from '@/store/orbitRuntime';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
+4 -1
View File
@@ -9,7 +9,10 @@ const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
vi.mock('@/lib/api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('@/features/orbit', () => ({ isInOrbitSession: () => inOrbit.value }));
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
isInOrbitSession: () => inOrbit.value,
}));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
}));
+68
View File
@@ -0,0 +1,68 @@
// Orbit seam. The audio core must react to an active Orbit session — suppress
// local playback-rate, catch a guest up to the host's live position, gate bulk
// queue ops behind a confirm dialog — but must not import @/features/orbit (iron
// rule). The orbit feature registers its runtime here at boot (orbitBulkGuard.ts
// module init, evaluated via the @/features/orbit barrel the topbar imports); the
// audio core reads this neutral surface.
//
// Default (unregistered) = no session: snapshot is neutral and bulkGuard allows —
// identical to today's behavior outside an Orbit session. A session can only start
// through the topbar, which loads the barrel (→ registers) before any session
// exists, so the registered runtime is always in place when it matters.
import type { OrbitRole, OrbitPhase, OrbitState } from '@/features/orbit'; // type-only (erased at runtime)
export interface OrbitSnapshot {
role: OrbitRole | null;
phase: OrbitPhase;
state: OrbitState | null;
}
const NEUTRAL: OrbitSnapshot = { role: null, phase: 'idle', state: null };
export interface OrbitRuntime {
getSnapshot(): OrbitSnapshot;
bulkGuard(count: number): Promise<boolean>;
}
let runtime: OrbitRuntime | null = null;
/** Orbit feature installs its store-backed snapshot + confirm-modal gate here. */
export function registerOrbitRuntime(rt: OrbitRuntime): void {
runtime = rt;
}
export function orbitSnapshot(): OrbitSnapshot {
return runtime?.getSnapshot() ?? NEUTRAL;
}
/**
* Ask before dropping many tracks into a shared Orbit queue. Resolves `true` when
* there's no session, `count <= 1`, or the user accepted; `false` only when an
* active-Orbit user cancelled. Default (no runtime registered) = allow.
*/
export function orbitBulkGuard(count: number): Promise<boolean> {
return runtime ? runtime.bulkGuard(count) : Promise.resolve(true);
}
// Pure derivations mirrored from the orbit feature (sessionActive.ts /
// orbitSession.ts / api/orbit.ts). Duplicated here so the audio core needs no
// feature import; the feature keeps its own copies (incl. the UI arg-form of
// isOrbitPlaybackSyncActive). Keep in sync if the orbit lifecycle phases change.
function isSyncingPhase(role: OrbitRole | null, phase: OrbitPhase): boolean {
if (role !== 'host' && role !== 'guest') return false;
return phase === 'active' || phase === 'joining' || phase === 'starting';
}
export function isInOrbitSession(): boolean {
const { role, phase } = orbitSnapshot();
return isSyncingPhase(role, phase);
}
export function isOrbitPlaybackSyncActive(): boolean {
const { role, phase } = orbitSnapshot();
return isSyncingPhase(role, phase);
}
export function estimateLivePosition(state: OrbitState, nowMs: number): number {
return state.isPlaying ? state.positionMs + (nowMs - state.positionAt) : state.positionMs;
}
+3 -4
View File
@@ -2,7 +2,7 @@ import { playbackReportStart, playbackReportStopped } from './playbackReportSess
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { orbitBulkGuard } from '@/features/orbit';
import { orbitBulkGuard, orbitSnapshot } from '@/store/orbitRuntime';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
computeAutodjManualBlendPlan,
@@ -53,7 +53,6 @@ import {
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { fetchWaveformBins, refreshWaveformForTrack } from '@/store/waveformRefresh';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { useOrbitStore } from '@/features/orbit';
import {
playbackSourceHintForResolvedUrl,
recordEnginePlayUrl,
@@ -135,7 +134,7 @@ export function runPlayTrack(
// suggestions mid-listen. Append instead — the dialog's
// "Add them all" copy already matches that semantic. Outside
// Orbit, proceed as a normal replace.
const role = useOrbitStore.getState().role;
const role = orbitSnapshot().role;
if (role === 'host' || role === 'guest') {
get().enqueue(queue, true);
} else {
@@ -157,7 +156,7 @@ export function runPlayTrack(
// their own show" path. `useOrbitGuest`'s `syncToHost` is also a
// guest-only call site, so it's never intercepted here.
if (!_orbitConfirmed && queue && queue.length === 1) {
const orbitRole = useOrbitStore.getState().role;
const orbitRole = orbitSnapshot().role;
if (orbitRole === 'host') {
const currentItems = get().queueItems;
const currentTrackId = currentItems[get().queueIndex]?.trackId;
+1 -1
View File
@@ -14,7 +14,7 @@ import {
shouldRestartPlaybackForRateChange,
type PlaybackRateSnapshot,
} from '../utils/audio/playbackRateRestart';
import { isOrbitPlaybackSyncActive } from '@/features/orbit';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
interface PlaybackRateState extends PlaybackRateSnapshot {
/** UI-only: smaller slider steps (Advanced). Not sent to the engine. */
+4 -1
View File
@@ -19,7 +19,10 @@ vi.mock('./playbackRateStore', () => ({
},
}));
vi.mock('../utils/audio/playbackRateHelpers', () => ({ isPlaybackRateApplied: () => false }));
vi.mock('@/features/orbit', () => ({ isOrbitPlaybackSyncActive: () => false }));
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
isOrbitPlaybackSyncActive: () => false,
}));
import { reportNowPlaying, reportPlayback } from '@/lib/api/subsonicScrobble';
import { isFeatureActiveForServer } from '../serverCapabilities/storeView';
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PlaybackReportState } from '@/lib/api/subsonicTypes';
import { FEATURE_PLAYBACK_REPORT } from '../serverCapabilities/catalog';
import { isFeatureActiveForServer } from '../serverCapabilities/storeView';
import { isPlaybackRateApplied } from '../utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '@/features/orbit';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
import { useAuthStore } from './authStore';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlaybackRateStore } from './playbackRateStore';
+2 -1
View File
@@ -44,7 +44,8 @@ vi.mock('@/music-network', () => {
};
});
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
+2 -1
View File
@@ -42,7 +42,8 @@ vi.mock('@/music-network', () => ({
getMusicNetworkRuntimeOrNull: () => runtimeMock,
}));
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
@@ -47,7 +47,8 @@ vi.mock('@/music-network', () => {
};
});
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
+2 -1
View File
@@ -26,7 +26,8 @@ vi.mock('@/lib/api/subsonic', async () => {
// `enqueue` / `enqueueAt` call `orbitBulkGuard` for multi-track inserts when
// the caller hasn't pre-confirmed. Force the guard to short-circuit through.
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
+1 -1
View File
@@ -5,7 +5,7 @@ import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { isOrbitPlaybackSyncActive } from '@/features/orbit';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
/** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack {
+1 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { orbitBulkGuard } from '@/features/orbit';
import { orbitBulkGuard } from '@/store/orbitRuntime';
import { useAuthStore } from './authStore';
import { setIsAudioPaused } from './engineState';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
+2 -3
View File
@@ -1,6 +1,6 @@
import { getSong } from '@/lib/api/subsonicLibrary';
import { invoke } from '@tauri-apps/api/core';
import { estimateLivePosition } from '@/features/orbit';
import { estimateLivePosition, orbitSnapshot } from '@/store/orbitRuntime';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import {
getPlaybackCacheServerKey,
@@ -22,7 +22,6 @@ import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from './loudnessGainCache';
import { useOrbitStore } from '@/features/orbit';
import {
playbackSourceHintForResolvedUrl,
recordEnginePlayUrl,
@@ -73,7 +72,7 @@ export function runResume(set: SetState, get: GetState): void {
// them back at the stale local position while the host is already
// two songs ahead. Covers PlayerBar, media keys, MPRIS — everything
// that funnels through resume().
const orbit = useOrbitStore.getState();
const orbit = orbitSnapshot();
const hostState = orbit.state;
if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) {
const trackId = hostState.currentTrack.trackId;
+2 -2
View File
@@ -1,7 +1,7 @@
import { usePlayerStore } from '../../store/playerStore';
import { resolveAlbumForActiveServer } from '@/store/mediaResolver';
import { songToTrack } from './songToTrack';
import { useOrbitStore } from '@/features/orbit';
import { orbitSnapshot } from '@/store/orbitRuntime';
import { fadeOut } from './fadeOut';
import { shouldAutodjInterruptBlend } from './autodjManualBlend';
import type { Track } from '../../store/playerStoreTypes';
@@ -27,7 +27,7 @@ async function startAlbumPlayback(tracks: Track[]): Promise<void> {
// playerStore bulk-gate also routes replaces into enqueue). Skip the
// fadeOut entirely — the current track keeps playing, the album goes
// onto the end of the queue after the user confirms the bulk dialog.
const orbitRole = useOrbitStore.getState().role;
const orbitRole = orbitSnapshot().role;
if (orbitRole === 'host' || orbitRole === 'guest') {
usePlayerStore.getState().enqueue(tracks);
return;