diff --git a/src/App.tsx b/src/App.tsx
index f88f4433..19878b7f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
+
@@ -1076,6 +1078,7 @@ export default function App() {
return (
+
);
diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx
index 5fc4567f..c79900d2 100644
--- a/src/components/AlbumTrackList.tsx
+++ b/src/components/AlbumTrackList.tsx
@@ -57,6 +57,8 @@ interface AlbumTrackListProps {
userRatingOverrides: Record;
starredSongs: Set;
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}
diff --git a/src/components/GlobalConfirmModal.tsx b/src/components/GlobalConfirmModal.tsx
new file mode 100644
index 00000000..c1958776
--- /dev/null
+++ b/src/components/GlobalConfirmModal.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 42476b35..b51c5cef 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -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',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index ca6fc677..480c7fa6 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -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',
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx
index 8f97687f..6b2d3047 100644
--- a/src/pages/AlbumDetail.tsx
+++ b/src/pages/AlbumDetail.tsx
@@ -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(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}
diff --git a/src/store/confirmModalStore.ts b/src/store/confirmModalStore.ts
new file mode 100644
index 00000000..423003d6
--- /dev/null
+++ b/src/store/confirmModalStore.ts
@@ -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;
+ confirm: () => void;
+ cancel: () => void;
+}
+
+export const useConfirmModalStore = create(set => ({
+ isOpen: false,
+ title: '',
+ message: '',
+ confirmLabel: '',
+ cancelLabel: undefined,
+ danger: false,
+
+ request: (req) =>
+ new Promise(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 });
+ },
+}));
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts
index 77073363..0c7c9962 100644
--- a/src/store/playerStore.ts
+++ b/src/store/playerStore.ts
@@ -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()(
},
// ── 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()(
},
// ── 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()(
});
},
- 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 = [
diff --git a/src/utils/orbitBulkGuard.ts b/src/utils/orbitBulkGuard.ts
new file mode 100644
index 00000000..0881fe4c
--- /dev/null
+++ b/src/utils/orbitBulkGuard.ts
@@ -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 {
+ 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'),
+ });
+}