mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
Merge branch 'main' into feat/waveform-loudness-cache
# Conflicts: # src/store/playerStore.ts
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user