mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) },
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user