refactor(orbit,playback): move feature-owned stores (helpModal→orbit; libraryPlaybackHint+playerBarLayout→playback)

This commit is contained in:
Psychotoxical
2026-06-30 20:16:55 +02:00
parent 03ce57feff
commit 982499d32b
13 changed files with 12 additions and 12 deletions
@@ -5,7 +5,7 @@ import {
ListMusic, Inbox, Sliders, LogOut,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useHelpModalStore } from '@/store/helpModalStore';
import { useHelpModalStore } from '@/features/orbit/store/helpModalStore';
import { SettingsSubSection } from '@/features/settings';
/**
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useHelpModalStore } from '@/store/helpModalStore';
import { useHelpModalStore } from '@/features/orbit/store/helpModalStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
endOrbitSession,
@@ -4,7 +4,7 @@ import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useAuthStore } from '@/store/authStore';
import { useHelpModalStore } from '@/store/helpModalStore';
import { useHelpModalStore } from '@/features/orbit/store/helpModalStore';
import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
import OrbitJoinModal from '@/features/orbit/components/OrbitJoinModal';
import OrbitWordmark from '@/features/orbit/components/OrbitWordmark';
@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface HelpModalStore {
isOpen: boolean;
open: () => void;
close: () => void;
}
/**
* App-wide toggle for the Orbit help modal. Two triggers — the launch
* popover "How does this work?" entry and the in-session bar help button
* — write to the same store so they share the one rendered modal. Not
* persisted; resets to closed on reload.
*/
export const useHelpModalStore = create<HelpModalStore>(set => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
@@ -35,7 +35,7 @@ import { useUtilityOverflowMenu } from '@/features/playback/hooks/useUtilityOver
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
} from '@/features/playback/store/playerBarLayoutStore';
export default function PlayerBar() {
const { t } = useTranslation();
@@ -8,7 +8,7 @@ import { PlayerPlaybackRateMenuSection } from '@/features/playback/components/pl
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
} from '@/features/playback/store/playerBarLayoutStore';
interface Props {
utilityMenuRef: React.RefObject<HTMLDivElement | null>;
@@ -19,7 +19,7 @@ import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel }
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
} from '@/features/playback/store/playerBarLayoutStore';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
@@ -9,7 +9,7 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from '@/store/libraryPlaybackHint';
import { notifyLibraryPlaybackHint } from '@/features/playback/store/libraryPlaybackHint';
import {
playListenSessionFinalize,
playListenSessionOnProgress,
@@ -0,0 +1,25 @@
import { librarySetPlaybackHint, type PlaybackHint } from '@/lib/api/library';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
/**
* Bridge from the audio lifecycle to the Rust library scheduler's
* bandwidth lane (spec §6.2.4, PR-5 kickoff Q3 — JS pushes the hint).
* Only fires when the local index is enabled for the active server,
* and dedupes repeated identical hints so we don't spam the IPC
* boundary on every progress tick.
*/
let lastHint: PlaybackHint | null = null;
export function notifyLibraryPlaybackHint(hint: PlaybackHint): void {
const activeId = useAuthStore.getState().activeServerId;
if (!useLibraryIndexStore.getState().isIndexEnabled(activeId)) {
lastHint = null;
return;
}
if (lastHint === hint) return;
lastHint = hint;
void librarySetPlaybackHint(hint).catch(() => {
/* best-effort — scheduler falls back to Idle parallelism */
});
}
@@ -0,0 +1,35 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
usePlayerBarLayoutStore,
} from '@/features/playback/store/playerBarLayoutStore';
describe('playerBarLayoutStore', () => {
beforeEach(() => {
usePlayerBarLayoutStore.getState().reset();
});
it('starts with all six items visible in declared order', () => {
const items = usePlayerBarLayoutStore.getState().items;
expect(items.map(i => i.id)).toEqual([
'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
]);
expect(items.every(i => i.visible)).toBe(true);
});
it('toggleItem flips the matching id without disturbing the others', () => {
usePlayerBarLayoutStore.getState().toggleItem('equalizer');
const items = usePlayerBarLayoutStore.getState().items;
expect(items.find(i => i.id === 'equalizer')?.visible).toBe(false);
expect(items.find(i => i.id === 'starRating')?.visible).toBe(true);
expect(items.find(i => i.id === 'miniPlayer')?.visible).toBe(true);
});
it('reset restores defaults after toggles', () => {
const { toggleItem, reset } = usePlayerBarLayoutStore.getState();
toggleItem('favorite');
toggleItem('lastfmLove');
reset();
expect(usePlayerBarLayoutStore.getState().items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS);
});
});
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type PlayerBarLayoutItemId =
| 'starRating'
| 'favorite'
// 'lastfmLove' is the enrichment-primary love button. The id is kept (not
// renamed to 'networkLove') because it is persisted in user layouts — renaming
// would silently drop the button from existing configs. Label is provider-neutral.
| 'lastfmLove'
| 'playbackRate'
| 'equalizer'
| 'miniPlayer';
export interface PlayerBarLayoutItemConfig {
id: PlayerBarLayoutItemId;
visible: boolean;
}
export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
{ id: 'starRating', visible: true },
{ id: 'favorite', visible: true },
{ id: 'lastfmLove', visible: true },
{ id: 'playbackRate', visible: true },
{ id: 'equalizer', visible: true },
{ id: 'miniPlayer', visible: true },
];
interface PlayerBarLayoutStore {
items: PlayerBarLayoutItemConfig[];
setItems: (items: PlayerBarLayoutItemConfig[]) => void;
toggleItem: (id: PlayerBarLayoutItemId) => void;
reset: () => void;
}
export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
persist(
(set) => ({
items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
setItems: (items) => set({ items }),
toggleItem: (id) => set((s) => ({
items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it),
})),
reset: () => set({ items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS }),
}),
{
name: 'psysonic_player_bar_layout',
onRehydrateStorage: () => (state) => {
if (!state) return;
const knownIds = new Set(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.map(i => i.id));
const safe = (state.items ?? [])
.filter((i): i is PlayerBarLayoutItemConfig =>
i != null && typeof i.id === 'string' && knownIds.has(i.id as PlayerBarLayoutItemId));
const seen = new Set(safe.map(i => i.id));
const missing = DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.filter(i => !seen.has(i.id));
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
)
);
@@ -4,7 +4,7 @@ import { useArtistLayoutStore } from '@/features/artist';
import { useAuthStore } from '@/store/authStore';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import { useHomeStore } from '@/features/home';
import { usePlayerBarLayoutStore } from '@/store/playerBarLayoutStore';
import { usePlayerBarLayoutStore } from '@/features/playback/store/playerBarLayoutStore';
import { usePlaylistLayoutStore } from '@/features/playlist';
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
import { useSidebarStore } from '@/features/sidebar';
@@ -5,7 +5,7 @@ import LastfmIcon from '@/ui/LastfmIcon';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
} from '@/features/playback/store/playerBarLayoutStore';
const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
starRating: 'settings.playerBarStarRating',