chore(orbit): guard bulk queue operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 11:24:46 +02:00
parent f8bcd57ed4
commit 7ba7d6bf25
9 changed files with 191 additions and 7 deletions
+3
View File
@@ -52,6 +52,7 @@ import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu'; import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal'; import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal'; import DownloadFolderModal from './components/DownloadFolderModal';
import GlobalConfirmModal from './components/GlobalConfirmModal';
import { DragDropProvider } from './contexts/DragDropContext'; import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal'; import TooltipPortal from './components/TooltipPortal';
import OverlayScrollArea from './components/OverlayScrollArea'; import OverlayScrollArea from './components/OverlayScrollArea';
@@ -508,6 +509,7 @@ function AppShell() {
<ContextMenu /> <ContextMenu />
<SongInfoModal /> <SongInfoModal />
<DownloadFolderModal /> <DownloadFolderModal />
<GlobalConfirmModal />
<TooltipPortal /> <TooltipPortal />
<AppUpdater /> <AppUpdater />
</div> </div>
@@ -1076,6 +1078,7 @@ export default function App() {
return ( return (
<DragDropProvider> <DragDropProvider>
<MiniPlayer /> <MiniPlayer />
<GlobalConfirmModal />
<TooltipPortal /> <TooltipPortal />
</DragDropProvider> </DragDropProvider>
); );
+11
View File
@@ -57,6 +57,8 @@ interface AlbumTrackListProps {
userRatingOverrides: Record<string, number>; userRatingOverrides: Record<string, number>;
starredSongs: Set<string>; starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void; 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; onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => 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; inSelectMode: boolean;
isContextMenuSong: boolean; isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void; onPlaySong: (song: SubsonicSong) => void;
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void; onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: AlbumTrackListProps['onContextMenu']; onContextMenu: AlbumTrackListProps['onContextMenu'];
@@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({
inSelectMode, inSelectMode,
isContextMenuSong, isContextMenuSong,
onPlaySong, onPlaySong,
onDoubleClickSong,
onRate, onRate,
onToggleSongStar, onToggleSongStar,
onContextMenu, onContextMenu,
@@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({
onPlaySong(song); 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 => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
setContextMenuSongId(song.id); setContextMenuSongId(song.id);
@@ -266,6 +275,7 @@ export default function AlbumTrackList({
userRatingOverrides, userRatingOverrides,
starredSongs, starredSongs,
onPlaySong, onPlaySong,
onDoubleClickSong,
onRate, onRate,
onToggleSongStar, onToggleSongStar,
onContextMenu, onContextMenu,
@@ -644,6 +654,7 @@ export default function AlbumTrackList({
inSelectMode={inSelectMode} inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id} isContextMenuSong={contextMenuSongId === song.id}
onPlaySong={onPlaySong} onPlaySong={onPlaySong}
onDoubleClickSong={onDoubleClickSong}
onRate={onRate} onRate={onRate}
onToggleSongStar={onToggleSongStar} onToggleSongStar={onToggleSongStar}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
+25
View File
@@ -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}
/>
);
}
+5
View File
@@ -177,6 +177,7 @@ export const deTranslation = {
}, },
albumDetail: { albumDetail: {
back: 'Zurück', back: 'Zurück',
orbitDoubleClickHint: 'Doppelklick fügt diesen Titel zur Orbit-Queue hinzu',
playAll: 'Alle abspielen', playAll: 'Alle abspielen',
shareAlbum: 'Album teilen', shareAlbum: 'Album teilen',
enqueue: 'Einreihen', enqueue: 'Einreihen',
@@ -1537,6 +1538,10 @@ export const deTranslation = {
ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen', ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen',
ctxAddedHostToast: 'Zur Session hinzugefügt', ctxAddedHostToast: 'Zur Session hinzugefügt',
ctxAddHostFailed: 'Hinzufügen fehlgeschlagen', 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', guestLive: 'Live',
guestPlaying: 'Läuft gerade', guestPlaying: 'Läuft gerade',
guestPaused: 'Pausiert', guestPaused: 'Pausiert',
+5
View File
@@ -178,6 +178,7 @@ export const enTranslation = {
}, },
albumDetail: { albumDetail: {
back: 'Back', back: 'Back',
orbitDoubleClickHint: 'Double-click to add this track to the Orbit queue',
playAll: 'Play All', playAll: 'Play All',
shareAlbum: 'Share album', shareAlbum: 'Share album',
enqueue: 'Enqueue', enqueue: 'Enqueue',
@@ -1540,6 +1541,10 @@ export const enTranslation = {
ctxAddToSessionHost: 'Add to Orbit session', ctxAddToSessionHost: 'Add to Orbit session',
ctxAddedHostToast: 'Added to the session', ctxAddedHostToast: 'Added to the session',
ctxAddHostFailed: "Couldn't add to 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', guestLive: 'Live',
guestPlaying: 'Playing now', guestPlaying: 'Playing now',
guestPaused: 'Paused', guestPaused: 'Paused',
+38 -1
View File
@@ -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 { useParams, useNavigate } from 'react-router-dom';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit';
import { useDownloadModalStore } from '../store/downloadModalStore'; import { useDownloadModalStore } from '../store/downloadModalStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore'; import { useOfflineJobStore } from '../store/offlineJobStore';
@@ -153,7 +155,24 @@ const handleShuffleAll = () => {
if (shuffled[0]) playTrack(shuffled[0], shuffled); 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) => { 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; if (!album) return;
const albumGenre = album.album.genre; const albumGenre = album.album.genre;
const tracks = album.songs.map(s => { const tracks = album.songs.map(s => {
@@ -165,6 +184,23 @@ const handleShuffleAll = () => {
playTrack(track, tracks); 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) => { const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating })); setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating); usePlayerStore.getState().setUserRatingOverride(songId, rating);
@@ -427,6 +463,7 @@ const handleShuffleAll = () => {
userRatingOverrides={userRatingOverrides} userRatingOverrides={userRatingOverrides}
starredSongs={mergedStarredSongs} starredSongs={mergedStarredSongs}
onPlaySong={handlePlaySong} onPlaySong={handlePlaySong}
onDoubleClickSong={orbitActive ? handleDoubleClickSong : undefined}
onRate={handleRate} onRate={handleRate}
onToggleSongStar={toggleSongStar} onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
+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 });
},
}));
+31 -6
View File
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore'; import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore'; import { useHotCacheStore } from './hotCacheStore';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
export interface Track { export interface Track {
id: string; id: string;
@@ -168,7 +169,9 @@ interface PlayerState {
setUserRatingOverride: (id: string, rating: number) => void; setUserRatingOverride: (id: string, rating: number) => void;
playRadio: (station: InternetRadioStation) => 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`. */ /** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */
reseedQueueForInstantMix: (track: Track) => void; reseedQueueForInstantMix: (track: Track) => void;
pause: () => void; pause: () => void;
@@ -193,8 +196,8 @@ interface PlayerState {
setVolume: (v: number) => void; setVolume: (v: number) => void;
updateReplayGainForCurrentTrack: () => void; updateReplayGainForCurrentTrack: () => void;
setProgress: (t: number, duration: number) => void; setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void; setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */ /** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
@@ -1206,7 +1209,17 @@ export const usePlayerStore = create<PlayerState>()(
}, },
// ── playTrack ──────────────────────────────────────────────────────────── // ── 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, // Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it. // this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) { if (Date.now() - lastGaplessSwitchTime < 500) {
@@ -1753,7 +1766,13 @@ export const usePlayerStore = create<PlayerState>()(
}, },
// ── queue management ───────────────────────────────────────────────────── // ── 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 => { set(state => {
// Insert before the first upcoming auto-added track so the // Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary. // "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 => { set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length)); const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [ const newQueue = [
+26
View File
@@ -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'),
});
}