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'), + }); +}