Merge branch 'main' into feat/waveform-loudness-cache

# Conflicts:
#	src/store/playerStore.ts
This commit is contained in:
Psychotoxical
2026-04-25 21:47:48 +02:00
76 changed files with 9658 additions and 263 deletions
+6
View File
@@ -64,6 +64,9 @@ interface AuthState {
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
/** Whether the "Orbit" topbar trigger is rendered. Users who never
* touch Orbit can hide it so the header stays uncluttered. */
showOrbitTrigger: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
/** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */
@@ -222,6 +225,7 @@ interface AuthState {
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setShowOrbitTrigger: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
setEnableBandsintown: (v: boolean) => void;
@@ -334,6 +338,7 @@ export const useAuthStore = create<AuthState>()(
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
showOrbitTrigger: true,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
enableBandsintown: false,
@@ -478,6 +483,7 @@ export const useAuthStore = create<AuthState>()(
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
setEnableBandsintown: (v) => set({ enableBandsintown: v }),
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
let _resolve: ((accepted: boolean) => void) | null = null;
interface ConfirmRequest {
title: string;
message: string;
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
}
interface ConfirmModalStore extends ConfirmRequest {
isOpen: boolean;
request: (req: ConfirmRequest) => Promise<boolean>;
confirm: () => void;
cancel: () => void;
}
export const useConfirmModalStore = create<ConfirmModalStore>(set => ({
isOpen: false,
title: '',
message: '',
confirmLabel: '',
cancelLabel: undefined,
danger: false,
request: (req) =>
new Promise<boolean>(resolve => {
// If a previous prompt is still pending, treat the old one as cancelled.
if (_resolve) _resolve(false);
_resolve = resolve;
set({ isOpen: true, ...req });
}),
confirm: () => {
_resolve?.(true);
_resolve = null;
set({ isOpen: false });
},
cancel: () => {
_resolve?.(false);
_resolve = null;
set({ isOpen: false });
},
}));
+19
View File
@@ -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 }),
}));
+16 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
export interface HomeSectionConfig {
id: HomeSectionId;
@@ -12,6 +12,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
{ id: 'hero', visible: true },
{ id: 'recent', visible: true },
{ id: 'discover', visible: true },
{ id: 'discoverSongs', visible: true },
{ id: 'discoverArtists', visible: true },
{ id: 'recentlyPlayed', visible: true },
{ id: 'starred', visible: true },
@@ -33,6 +34,19 @@ export const useHomeStore = create<HomeStore>()(
})),
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
}),
{ name: 'psysonic_home' }
{
name: 'psysonic_home',
onRehydrateStorage: () => (state) => {
// Append any sections introduced after the user first persisted their order,
// so new defaults show up without forcing a manual Reset.
if (!state) return;
const safe = (state.sections ?? []).filter(
(s): s is HomeSectionConfig => s != null && typeof s.id === 'string',
);
const known = new Set(safe.map(s => s.id));
const missing = DEFAULT_HOME_SECTIONS.filter(s => !known.has(s.id));
state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
)
);
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
import type { ServerProfile } from './authStore';
let _resolve: ((server: ServerProfile | null) => void) | null = null;
interface OrbitAccountPickerStore {
isOpen: boolean;
accounts: ServerProfile[];
/** Open the picker with the given candidates. Resolves with the chosen
* server or null if the user cancels. */
request: (accounts: ServerProfile[]) => Promise<ServerProfile | null>;
pick: (server: ServerProfile) => void;
cancel: () => void;
}
export const useOrbitAccountPickerStore = create<OrbitAccountPickerStore>(set => ({
isOpen: false,
accounts: [],
request: (accounts) =>
new Promise<ServerProfile | null>(resolve => {
// If another picker is already pending, treat the previous one as cancelled.
if (_resolve) _resolve(null);
_resolve = resolve;
set({ isOpen: true, accounts });
}),
pick: (server) => {
_resolve?.(server);
_resolve = null;
set({ isOpen: false });
},
cancel: () => {
_resolve?.(null);
_resolve = null;
set({ isOpen: false });
},
}));
+147
View File
@@ -0,0 +1,147 @@
import { create } from 'zustand';
import type { OrbitState } from '../api/orbit';
/**
* Orbit — local session store.
*
* Mirrors the remote canonical state for the UI, plus a handful of
* client-only fields (our role in the session, the playlist ids we're
* bound to, lifecycle phase). Not persisted — a session is transient by
* design; if the app restarts mid-session, we re-join on next open
* rather than resurrect stale local state.
*
* Phase 1 is intentionally thin: only `set`/`reset` plumbing so later
* phases (host/guest lifecycle, track pipeline) can drop into place
* without touching the store shape.
*/
export type OrbitRole = 'host' | 'guest';
/** Fine-grained lifecycle phase. Drives which modal/indicator UI is visible. */
export type OrbitPhase =
/** No session bound. */
| 'idle'
/** Host: creating the session playlist and seeding state. */
| 'starting'
/** Guest: auth + lookup before commit. */
| 'joining'
/** Session established; polling cycle active. */
| 'active'
/** Host ended the session; showing exit modal. */
| 'ended'
/** Unrecoverable error (server unreachable, playlist vanished, etc.). */
| 'error';
interface OrbitStore {
/** Current role in the session, or null when idle. */
role: OrbitRole | null;
/** Active session id, or null. */
sessionId: string | null;
/** Navidrome playlist id of the canonical session playlist. */
sessionPlaylistId: string | null;
/** Navidrome playlist id of our own outbox (exists for both host and guest). */
outboxPlaylistId: string | null;
/** Lifecycle phase. */
phase: OrbitPhase;
/** Latest-known canonical state (last poll). Null while starting/joining. */
state: OrbitState | null;
/** Human-readable error when `phase === 'error'`. */
errorMessage: string | null;
/**
* Wall-clock ms when this client joined the current session (host: start
* time, guest: join time). Used to disambiguate stale `removed`-list
* entries from a fresh re-join after a remove. Null when idle.
*/
joinedAt: number | null;
/**
* Guest-only: track ids the local client has suggested but the host
* hasn't yet merged into the shared queue. Filled by
* `suggestOrbitTrack`, drained by the guest tick once the id appears
* in `state.queue` / `state.currentTrack`. In-memory only — a rejoin
* starts empty, any still-pending ids either land or get dropped by
* the host's next sweep anyway.
*/
pendingSuggestions: string[];
/**
* Host-only: suggestionKeys (see suggestionKey() in utils/orbit) that
* the host has already merged into the play queue — whether via
* auto-approve or an explicit Approve button. Stops the host tick
* from re-inserting the same item on every sweep.
*/
mergedSuggestionKeys: string[];
/**
* Host-only: suggestionKeys that the host explicitly declined. Keeps
* them out of the merge pipeline AND out of the pending-approvals UI
* so a declined suggestion doesn't keep begging for attention.
*/
declinedSuggestionKeys: string[];
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void;
setRole: (role: OrbitRole | null) => void;
setSessionBinding: (args: {
sessionId: string | null;
sessionPlaylistId: string | null;
outboxPlaylistId: string | null;
}) => void;
setState: (state: OrbitState | null) => void;
setError: (message: string | null) => void;
addPendingSuggestion: (trackId: string) => void;
/** Keep only the pending ids that are NOT yet observable in the shared queue. */
reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void;
/** Host: mark a suggestion as merged so the tick stops re-proposing it. */
addMergedSuggestion: (key: string) => void;
/** Host: mark a suggestion as declined so the approval UI and tick ignore it. */
addDeclinedSuggestion: (key: string) => void;
/** Tear down the session locally. Does NOT clean up remote playlists. */
reset: () => void;
}
const initialState = {
role: null,
sessionId: null,
sessionPlaylistId: null,
outboxPlaylistId: null,
phase: 'idle' as OrbitPhase,
state: null,
errorMessage: null,
joinedAt: null,
pendingSuggestions: [] as string[],
mergedSuggestionKeys: [] as string[],
declinedSuggestionKeys: [] as string[],
} satisfies Omit<OrbitStore,
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
| 'addPendingSuggestion' | 'reconcilePendingSuggestions'
| 'addMergedSuggestion' | 'addDeclinedSuggestion' | 'reset'
>;
export const useOrbitStore = create<OrbitStore>()((set) => ({
...initialState,
setPhase: (phase) => set({ phase }),
setRole: (role) => set({ role }),
setSessionBinding: ({ sessionId, sessionPlaylistId, outboxPlaylistId }) =>
set({ sessionId, sessionPlaylistId, outboxPlaylistId }),
setState: (state) => set({ state }),
setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }),
addPendingSuggestion: (trackId) => set(s => (
s.pendingSuggestions.includes(trackId)
? s
: { pendingSuggestions: [...s.pendingSuggestions, trackId] }
)),
reconcilePendingSuggestions: (landedTrackIds) => set(s => {
const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id));
return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next };
}),
addMergedSuggestion: (key) => set(s => (
s.mergedSuggestionKeys.includes(key)
? s
: { mergedSuggestionKeys: [...s.mergedSuggestionKeys, key] }
)),
addDeclinedSuggestion: (key) => set(s => (
s.declinedSuggestionKeys.includes(key)
? s
: { declinedSuggestionKeys: [...s.declinedSuggestionKeys, key] }
)),
reset: () => set({ ...initialState }),
}));
+113 -6
View File
@@ -11,6 +11,9 @@ import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
import { onAnalysisStorageChanged } from './analysisSync';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useOrbitStore } from './orbitStore';
import { estimateLivePosition } from '../api/orbit';
export interface Track {
id: string;
@@ -182,7 +185,9 @@ interface PlayerState {
setUserRatingOverride: (id: string, rating: number) => void;
playRadio: (station: InternetRadioStation) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
/** `_orbitConfirmed` is an internal bypass flag — callers outside the
* orbit bulk-gate should leave it `undefined`. */
playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean) => void;
/** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */
reseedQueueForInstantMix: (track: Track) => void;
pause: () => void;
@@ -207,8 +212,8 @@ interface PlayerState {
setVolume: (v: number) => void;
updateReplayGainForCurrentTrack: () => void;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void;
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
@@ -228,6 +233,8 @@ interface PlayerState {
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
shuffleQueue: () => void;
/** Shuffle only the tracks after the current one — leaves played history intact. */
shuffleUpcomingQueue: () => void;
toggleLastfmLove: () => void;
setLastfmLoved: (v: boolean) => void;
@@ -1618,7 +1625,35 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue, manual = true) => {
playTrack: (track, queue, manual = true, _orbitConfirmed = false) => {
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
// the current queue (Play All / Play Album / Play Playlist / Hero
// play buttons). Navigation calls — queue-row click, next(),
// previous() — pass the existing queue back through playTrack just
// to move the index; they are not bulk operations and must not
// trigger the confirm dialog (#234 regression).
if (!_orbitConfirmed && queue && queue.length > 1) {
const current = get().queue;
const sameAsCurrent = queue.length === current.length
&& queue.every((t, i) => current[i]?.id === t.id);
if (!sameAsCurrent) {
void orbitBulkGuard(queue.length).then(ok => {
if (!ok) return;
// Inside an Orbit session a bulk replace would discard guest
// 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;
if (role === 'host' || role === 'guest') {
get().enqueue(queue, true);
} else {
get().playTrack(track, queue, manual, true);
}
});
return;
}
}
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
@@ -1824,6 +1859,50 @@ export const usePlayerStore = create<PlayerState>()(
resume: () => {
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
// Orbit guest: resume means "catch up to the host's live stream".
// The user hit pause at some earlier point; resuming shouldn't drop
// 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 hostState = orbit.state;
if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) {
const trackId = hostState.currentTrack.trackId;
const targetMs = estimateLivePosition(hostState, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
const localTrackId = get().currentTrack?.id;
void (async () => {
try {
const song = await getSong(trackId);
if (!song) return;
const track = songToTrack(song);
const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
if (localTrackId === trackId) {
// Same track: seek + un-pause via the Rust engine directly.
// Bypasses this resume() branch re-entry via the early return below.
get().seek(fraction);
if (isAudioPaused) {
invoke('audio_resume').catch(console.error);
isAudioPaused = false;
set({ isPlaying: true });
} else {
set({ isPlaying: true });
}
} else {
// Host has a different track — load it (`_orbitConfirmed=true`
// skips the bulk gate; single-track play isn't a bulk replace
// anyway). Seek after a short defer once the engine loads.
get().playTrack(track, [track], false, true);
window.setTimeout(() => {
if (get().currentTrack?.id === trackId) get().seek(fraction);
}, 400);
}
} catch { /* silent */ }
})();
return;
}
if (get().currentRadio) {
radioAudio.play().catch(console.error);
set({ isPlaying: true });
@@ -2175,7 +2254,13 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── queue management ─────────────────────────────────────────────────────
enqueue: (tracks) => {
enqueue: (tracks, _orbitConfirmed = false) => {
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueue(tracks, true);
});
return;
}
set(state => {
// Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary.
@@ -2218,7 +2303,13 @@ export const usePlayerStore = create<PlayerState>()(
});
},
enqueueAt: (tracks, insertIndex) => {
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueueAt(tracks, insertIndex, true);
});
return;
}
set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [
@@ -2271,6 +2362,22 @@ export const usePlayerStore = create<PlayerState>()(
syncQueueToServer(result, currentTrack, get().currentTime);
},
shuffleUpcomingQueue: () => {
const { queue, queueIndex, currentTrack } = get();
const upcomingStart = queueIndex + 1;
const upcomingCount = queue.length - upcomingStart;
if (upcomingCount < 2) return;
const head = queue.slice(0, upcomingStart);
const upcoming = queue.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
const result = [...head, ...upcoming];
set({ queue: result });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
+1
View File
@@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mainstage', visible: true },
{ id: 'newReleases', visible: true },
{ id: 'allAlbums', visible: true },
{ id: 'tracks', visible: true },
{ id: 'randomPicker', visible: true },
{ id: 'randomMix', visible: true },
{ id: 'randomAlbums', visible: true },