mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
chore(orbit): guard bulk queue operations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,7 @@ import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
||||
import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import OverlayScrollArea from './components/OverlayScrollArea';
|
||||
@@ -508,6 +509,7 @@ function AppShell() {
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
</div>
|
||||
@@ -1076,6 +1078,7 @@ export default function App() {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<GlobalConfirmModal />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,8 @@ interface AlbumTrackListProps {
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
|
||||
onDoubleClickSong?: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
@@ -80,6 +82,7 @@ interface TrackRowProps {
|
||||
inSelectMode: boolean;
|
||||
isContextMenuSong: boolean;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onDoubleClickSong?: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: AlbumTrackListProps['onContextMenu'];
|
||||
@@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
inSelectMode,
|
||||
isContextMenuSong,
|
||||
onPlaySong,
|
||||
onDoubleClickSong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
@@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={onDoubleClickSong ? e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (e.ctrlKey || e.metaKey || inSelectMode) return;
|
||||
onDoubleClickSong(song);
|
||||
} : undefined}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
@@ -266,6 +275,7 @@ export default function AlbumTrackList({
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onDoubleClickSong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
@@ -644,6 +654,7 @@ export default function AlbumTrackList({
|
||||
inSelectMode={inSelectMode}
|
||||
isContextMenuSong={contextMenuSongId === song.id}
|
||||
onPlaySong={onPlaySong}
|
||||
onDoubleClickSong={onDoubleClickSong}
|
||||
onRate={onRate}
|
||||
onToggleSongStar={onToggleSongStar}
|
||||
onContextMenu={onContextMenu}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useConfirmModalStore } from '../store/confirmModalStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
/**
|
||||
* App-level singleton renderer for the global confirm modal. Mount once
|
||||
* in App.tsx; any code path can then call
|
||||
* `useConfirmModalStore.getState().request(...)` and await the user's decision.
|
||||
*/
|
||||
export default function GlobalConfirmModal() {
|
||||
const { isOpen, title, message, confirmLabel, cancelLabel, danger, confirm, cancel } =
|
||||
useConfirmModalStore();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
confirmLabel={confirmLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
danger={danger}
|
||||
onConfirm={confirm}
|
||||
onCancel={cancelLabel ? cancel : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,7 @@ export const deTranslation = {
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
orbitDoubleClickHint: 'Doppelklick fügt diesen Titel zur Orbit-Queue hinzu',
|
||||
playAll: 'Alle abspielen',
|
||||
shareAlbum: 'Album teilen',
|
||||
enqueue: 'Einreihen',
|
||||
@@ -1537,6 +1538,10 @@ export const deTranslation = {
|
||||
ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen',
|
||||
ctxAddedHostToast: 'Zur Session hinzugefügt',
|
||||
ctxAddHostFailed: 'Hinzufügen fehlgeschlagen',
|
||||
bulkConfirmTitle: 'Wirklich alles in die Orbit-Queue?',
|
||||
bulkConfirmBody: 'Das würde {{count}} Titel auf einmal in die geteilte Queue werfen. Für deine Gäste ist das eine Menge. Trotzdem hinzufügen?',
|
||||
bulkConfirmYes: 'Alle hinzufügen',
|
||||
bulkConfirmNo: 'Abbrechen',
|
||||
guestLive: 'Live',
|
||||
guestPlaying: 'Läuft gerade',
|
||||
guestPaused: 'Pausiert',
|
||||
|
||||
@@ -178,6 +178,7 @@ export const enTranslation = {
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Back',
|
||||
orbitDoubleClickHint: 'Double-click to add this track to the Orbit queue',
|
||||
playAll: 'Play All',
|
||||
shareAlbum: 'Share album',
|
||||
enqueue: 'Enqueue',
|
||||
@@ -1540,6 +1541,10 @@ export const enTranslation = {
|
||||
ctxAddToSessionHost: 'Add to Orbit session',
|
||||
ctxAddedHostToast: 'Added to the session',
|
||||
ctxAddHostFailed: "Couldn't add to session",
|
||||
bulkConfirmTitle: 'Add all of this to the Orbit queue?',
|
||||
bulkConfirmBody: "This would drop {{count}} tracks into the shared queue in one go. That's a lot for your guests to notice. Go ahead?",
|
||||
bulkConfirmYes: 'Add them all',
|
||||
bulkConfirmNo: 'Cancel',
|
||||
guestLive: 'Live',
|
||||
guestPlaying: 'Playing now',
|
||||
guestPaused: 'Paused',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
@@ -153,7 +155,24 @@ const handleShuffleAll = () => {
|
||||
if (shuffled[0]) playTrack(shuffled[0], shuffled);
|
||||
};
|
||||
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const orbitActive = orbitRole === 'host' || orbitRole === 'guest';
|
||||
const orbitClickTimerRef = useRef<number | null>(null);
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
// Orbit-Modus: ein Single-Click würde das ganze Album in die geteilte
|
||||
// Queue schaufeln — für eine kollaborative Session zu destruktiv.
|
||||
// Wir schlucken den Click und blenden einen Hint ein; ein folgender
|
||||
// Doppelklick cancelt den Timer und fügt stattdessen nur diesen Song
|
||||
// via handleDoubleClickSong hinzu.
|
||||
if (orbitActive) {
|
||||
if (orbitClickTimerRef.current !== null) return;
|
||||
orbitClickTimerRef.current = window.setTimeout(() => {
|
||||
orbitClickTimerRef.current = null;
|
||||
showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info');
|
||||
}, 220);
|
||||
return;
|
||||
}
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
@@ -165,6 +184,23 @@ const handleShuffleAll = () => {
|
||||
playTrack(track, tracks);
|
||||
};
|
||||
|
||||
const handleDoubleClickSong = (song: SubsonicSong) => {
|
||||
if (!orbitActive) return;
|
||||
if (orbitClickTimerRef.current !== null) {
|
||||
clearTimeout(orbitClickTimerRef.current);
|
||||
orbitClickTimerRef.current = null;
|
||||
}
|
||||
if (orbitRole === 'guest') {
|
||||
suggestOrbitTrack(song.id)
|
||||
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'));
|
||||
} else {
|
||||
hostEnqueueToOrbit(song.id)
|
||||
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
@@ -427,6 +463,7 @@ const handleShuffleAll = () => {
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
starredSongs={mergedStarredSongs}
|
||||
onPlaySong={handlePlaySong}
|
||||
onDoubleClickSong={orbitActive ? handleDoubleClickSong : undefined}
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
onContextMenu={openContextMenu}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
import { useHotCacheStore } from './hotCacheStore';
|
||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
@@ -168,7 +169,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;
|
||||
@@ -193,8 +196,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. */
|
||||
@@ -1206,7 +1209,17 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── playTrack ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue, manual = true) => {
|
||||
playTrack: (track, queue, manual = true, _orbitConfirmed = false) => {
|
||||
// Orbit bulk-gate: a >1-track queue inside an active session is
|
||||
// a "Play All / Play Album"-style action — confirm with the user
|
||||
// before it lands on every guest's player.
|
||||
if (!_orbitConfirmed && queue && queue.length > 1) {
|
||||
void orbitBulkGuard(queue.length).then(ok => {
|
||||
if (ok) 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) {
|
||||
@@ -1753,7 +1766,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.
|
||||
@@ -1796,7 +1815,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 = [
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useConfirmModalStore } from '../store/confirmModalStore';
|
||||
import i18n from '../i18n';
|
||||
|
||||
/**
|
||||
* Ask the user before dropping many tracks into the shared Orbit queue.
|
||||
*
|
||||
* Returns `true` when there's no active Orbit session, when `count <= 1`, or
|
||||
* 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).
|
||||
*/
|
||||
export async function orbitBulkGuard(count: number): Promise<boolean> {
|
||||
const role = useOrbitStore.getState().role;
|
||||
if (role !== 'host' && role !== 'guest') return true;
|
||||
if (count <= 1) return true;
|
||||
|
||||
return useConfirmModalStore.getState().request({
|
||||
title: i18n.t('orbit.bulkConfirmTitle'),
|
||||
message: i18n.t('orbit.bulkConfirmBody', { count }),
|
||||
confirmLabel: i18n.t('orbit.bulkConfirmYes'),
|
||||
cancelLabel: i18n.t('orbit.bulkConfirmNo'),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user