From 651a2adba4e671ca330f6d6a2975eb5e4173883f Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:19:22 +0200 Subject: [PATCH] =?UTF-8?q?refactor(decouple):=20orbit=20seam=20=E2=80=94?= =?UTF-8?q?=20invert=20orbit=20off=20the=20audio=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/features/orbit/utils/orbitBulkGuard.ts | 16 ++++- src/store/nextAction.ts | 2 +- src/store/nextActionOrbitRadio.test.ts | 5 +- src/store/orbitRuntime.ts | 68 +++++++++++++++++++ src/store/playTrackAction.ts | 7 +- src/store/playbackRateStore.ts | 2 +- src/store/playbackReportSession.test.ts | 5 +- src/store/playbackReportSession.ts | 2 +- src/store/playerStore.events.test.ts | 3 +- src/store/playerStore.misc.test.ts | 3 +- src/store/playerStore.playbackActions.test.ts | 3 +- src/store/playerStore.queue.test.ts | 3 +- src/store/previewStore.ts | 2 +- src/store/queueMutationActions.ts | 2 +- src/store/resumeAction.ts | 5 +- src/utils/playback/playAlbum.ts | 4 +- 16 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 src/store/orbitRuntime.ts diff --git a/src/features/orbit/utils/orbitBulkGuard.ts b/src/features/orbit/utils/orbitBulkGuard.ts index 4774762c..8a39c40e 100644 --- a/src/features/orbit/utils/orbitBulkGuard.ts +++ b/src/features/orbit/utils/orbitBulkGuard.ts @@ -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 { const role = useOrbitStore.getState().role; @@ -24,3 +25,14 @@ export async function orbitBulkGuard(count: number): Promise { 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, +}); diff --git a/src/store/nextAction.ts b/src/store/nextAction.ts index 6438f4b6..226cf560 100644 --- a/src/store/nextAction.ts +++ b/src/store/nextAction.ts @@ -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'; diff --git a/src/store/nextActionOrbitRadio.test.ts b/src/store/nextActionOrbitRadio.test.ts index 6ddcebff..998a85fc 100644 --- a/src/store/nextActionOrbitRadio.test.ts +++ b/src/store/nextActionOrbitRadio.test.ts @@ -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()), + isInOrbitSession: () => inOrbit.value, +})); vi.mock('./authStore', () => ({ useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) }, })); diff --git a/src/store/orbitRuntime.ts b/src/store/orbitRuntime.ts new file mode 100644 index 00000000..97cba0c6 --- /dev/null +++ b/src/store/orbitRuntime.ts @@ -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; +} + +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 { + 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; +} diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 69c88b00..f6d31c24 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -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; diff --git a/src/store/playbackRateStore.ts b/src/store/playbackRateStore.ts index 72e0e006..4af884fc 100644 --- a/src/store/playbackRateStore.ts +++ b/src/store/playbackRateStore.ts @@ -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. */ diff --git a/src/store/playbackReportSession.test.ts b/src/store/playbackReportSession.test.ts index 7b2f3e08..9ff7530a 100644 --- a/src/store/playbackReportSession.test.ts +++ b/src/store/playbackReportSession.test.ts @@ -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()), + isOrbitPlaybackSyncActive: () => false, +})); import { reportNowPlaying, reportPlayback } from '@/lib/api/subsonicScrobble'; import { isFeatureActiveForServer } from '../serverCapabilities/storeView'; diff --git a/src/store/playbackReportSession.ts b/src/store/playbackReportSession.ts index b20a803a..b203cfe7 100644 --- a/src/store/playbackReportSession.ts +++ b/src/store/playbackReportSession.ts @@ -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'; diff --git a/src/store/playerStore.events.test.ts b/src/store/playerStore.events.test.ts index d934d75c..cbc21fad 100644 --- a/src/store/playerStore.events.test.ts +++ b/src/store/playerStore.events.test.ts @@ -44,7 +44,8 @@ vi.mock('@/music-network', () => { }; }); -vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({ +vi.mock('@/store/orbitRuntime', async (importOriginal) => ({ + ...(await importOriginal()), orbitBulkGuard: vi.fn(async () => true), })); diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts index c3a79a4e..f7b773cf 100644 --- a/src/store/playerStore.misc.test.ts +++ b/src/store/playerStore.misc.test.ts @@ -42,7 +42,8 @@ vi.mock('@/music-network', () => ({ getMusicNetworkRuntimeOrNull: () => runtimeMock, })); -vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({ +vi.mock('@/store/orbitRuntime', async (importOriginal) => ({ + ...(await importOriginal()), orbitBulkGuard: vi.fn(async () => true), })); diff --git a/src/store/playerStore.playbackActions.test.ts b/src/store/playerStore.playbackActions.test.ts index 723482dd..05f514be 100644 --- a/src/store/playerStore.playbackActions.test.ts +++ b/src/store/playerStore.playbackActions.test.ts @@ -47,7 +47,8 @@ vi.mock('@/music-network', () => { }; }); -vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({ +vi.mock('@/store/orbitRuntime', async (importOriginal) => ({ + ...(await importOriginal()), orbitBulkGuard: vi.fn(async () => true), })); diff --git a/src/store/playerStore.queue.test.ts b/src/store/playerStore.queue.test.ts index 8526aaed..a33a27e9 100644 --- a/src/store/playerStore.queue.test.ts +++ b/src/store/playerStore.queue.test.ts @@ -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()), orbitBulkGuard: vi.fn(async () => true), })); diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts index 2d724c3e..936e0ae9 100644 --- a/src/store/previewStore.ts +++ b/src/store/previewStore.ts @@ -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 { diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 7c2be7d6..49474abd 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -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'; diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 7a5e8c2e..b151e4b0 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -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; diff --git a/src/utils/playback/playAlbum.ts b/src/utils/playback/playAlbum.ts index fb1d6b8a..b74911f0 100644 --- a/src/utils/playback/playAlbum.ts +++ b/src/utils/playback/playAlbum.ts @@ -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 { // 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;