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;