From 23edac69effb47cb81f12050cc70380f249445b3 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:39:57 +0200 Subject: [PATCH] chore(orbit): refine song-row click semantics Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitSongRowBehavior.ts | 57 ++++++++++++++++++++++++++++ src/pages/AdvancedSearch.tsx | 7 +++- src/pages/AlbumDetail.tsx | 40 +++---------------- src/pages/ArtistDetail.tsx | 9 ++++- src/pages/Favorites.tsx | 11 +++++- src/pages/PlaylistDetail.tsx | 11 +++++- src/pages/RandomMix.tsx | 12 ++++-- src/pages/SearchResults.tsx | 4 ++ 8 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 src/hooks/useOrbitSongRowBehavior.ts diff --git a/src/hooks/useOrbitSongRowBehavior.ts b/src/hooks/useOrbitSongRowBehavior.ts new file mode 100644 index 00000000..20d38785 --- /dev/null +++ b/src/hooks/useOrbitSongRowBehavior.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit'; +import { showToast } from '../utils/toast'; + +/** + * Shared behaviour for song rows that in "normal mode" swallow a full list + * into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites, + * ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch). + * + * In an active Orbit session this is too destructive — the list would + * propagate to every guest's player. Instead: + * + * - `queueHint()` — show a toast telling the user to double-click. + * Safe to call on every single-click; 220 ms debounce + * suppresses the pileup that browsers emit before a + * dblclick fires. + * - `addTrackToOrbit(songId)` — cancel any pending hint and add just that + * one track: suggestOrbitTrack for guests, + * hostEnqueueToOrbit for the host. + * + * `orbitActive` is the gate — when false, callers should skip the hint and + * run their original bulk-play path unchanged. + */ +export function useOrbitSongRowBehavior() { + const { t } = useTranslation(); + const orbitRole = useOrbitStore(s => s.role); + const orbitActive = orbitRole === 'host' || orbitRole === 'guest'; + const clickTimerRef = useRef(null); + + const queueHint = useCallback(() => { + if (clickTimerRef.current !== null) return; + clickTimerRef.current = window.setTimeout(() => { + clickTimerRef.current = null; + showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info'); + }, 220); + }, [t]); + + const addTrackToOrbit = useCallback((songId: string) => { + if (clickTimerRef.current !== null) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + if (orbitRole === 'guest') { + suggestOrbitTrack(songId) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); + } else if (orbitRole === 'host') { + hostEnqueueToOrbit(songId) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + } + }, [orbitRole, t]); + + return { orbitActive, queueHint, addTrackToOrbit }; +} diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 8ee5c83c..fea83236 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -11,6 +11,7 @@ import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import CustomSelect from '../components/CustomSelect'; import { useDragDrop } from '../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useAuthStore } from '../store/authStore'; import { useShallow } from 'zustand/react/shallow'; @@ -44,6 +45,8 @@ export default function AdvancedSearch() { })) ); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); @@ -302,7 +305,7 @@ export default function AdvancedSearch() { key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }} - onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))} + onDoubleClick={() => orbitActive ? addTrackToOrbit(song.id) : playTrack(track, results.songs.map(songToTrack))} role="row" onContextMenu={e => { e.preventDefault(); @@ -328,7 +331,7 @@ export default function AdvancedSearch() { diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 6b2d3047..302aaff5 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, 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 { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; @@ -155,24 +154,10 @@ 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 { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); 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 (orbitActive) { queueHint(); return; } if (!album) return; const albumGenre = album.album.genre; const tracks = album.songs.map(s => { @@ -184,22 +169,7 @@ 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 handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id); const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 0b7c94bc..1d144c32 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -6,6 +6,7 @@ import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp, Share2 } from 'lucide-react'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; @@ -74,6 +75,7 @@ export default function ArtistDetail() { const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); @@ -702,14 +704,19 @@ export default function ArtistDetail() { style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }} onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; + if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={(e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} > -
{ e.stopPropagation(); playTopSongWithContinuation(idx); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}> {currentTrack?.id === song.id && isPlaying &&
} {idx + 1} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 3a088378..bcbc7826 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -17,6 +17,7 @@ import { unstar } from '../api/subsonic'; import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; import { useSelectionStore } from '../store/selectionStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import GenreFilterBar from '../components/GenreFilterBar'; @@ -78,6 +79,7 @@ export default function Favorites() { const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const playRadio = usePlayerStore(s => s.playRadio); const stop = usePlayerStore(s => s.stop); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -629,10 +631,17 @@ export default function Favorites() { toggleSelect(song.id, i, false); } else if (inSelectMode) { toggleSelect(song.id, i, e.shiftKey); + } else if (orbitActive) { + queueHint(); } else { playTrack(track, visibleSongs.map(songToTrack)); } }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || inSelectMode) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }} role="row" onMouseDown={e => { @@ -660,7 +669,7 @@ export default function Favorites() { {visibleCols.map(colDef => { switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}> { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 69ad6faf..2b03d1f4 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -17,6 +17,7 @@ import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; @@ -237,6 +238,7 @@ export default function PlaylistDetail() { userRatingOverrides: s.userRatingOverrides, })) ); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist); @@ -1611,10 +1613,17 @@ export default function PlaylistDetail() { toggleSelect(song.id, i, false); } else if (selectedIds.size > 0) { toggleSelect(song.id, i, e.shiftKey); + } else if (orbitActive) { + queueHint(); } else { playTrack(displayedTracks[i], displayedTracks); } }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); @@ -1625,7 +1634,7 @@ export default function PlaylistDetail() { const inSelectMode = selectedIds.size > 0; switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}> { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index a6c2c608..35a0d06e 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -6,6 +6,7 @@ import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { fetchRandomMixSongsUntilFull, getMixMinRatingsConfigFromAuth, @@ -32,6 +33,7 @@ export default function RandomMix() { const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const playTrack = usePlayerStore(s => s.playTrack); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -427,7 +429,8 @@ export default function RandomMix() { key={song.id} className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }} - onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} + onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} + onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} role="row" onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onMouseDown={e => { @@ -446,7 +449,7 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1} @@ -526,7 +529,8 @@ export default function RandomMix() { key={song.id} className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }} - onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} + onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} + onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} role="row" onContextMenu={e => { e.preventDefault(); @@ -549,7 +553,7 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1} diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index e854edc6..91b6afa7 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -8,6 +8,7 @@ import ArtistRow from '../components/ArtistRow'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useThemeStore } from '../store/themeStore'; import { useShallow } from 'zustand/react/shallow'; @@ -51,7 +52,10 @@ export default function SearchResults() { const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { + if (orbitActive) { addTrackToOrbit(song.id); return; } playTrack(songToTrack(song), list.map(songToTrack)); };