chore(orbit): refine song-row click semantics

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 11:39:57 +02:00
parent 7ba7d6bf25
commit 23edac69ef
8 changed files with 107 additions and 44 deletions
+5 -2
View File
@@ -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<string | null>(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() {
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playTrack(track, results.songs.map(songToTrack)); }}
onClick={e => { e.stopPropagation(); if (orbitActive) { addTrackToOrbit(song.id); return; } playTrack(track, results.songs.map(songToTrack)); }}
>
<Play size={13} fill="currentColor" />
</button>
+5 -35
View File
@@ -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<number | null>(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 }));
+8 -1
View File
@@ -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');
}}
>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTopSongWithContinuation(idx); }}>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
+10 -1
View File
@@ -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 (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}>
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
+10 -1
View File
@@ -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 (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}>
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
+8 -4
View File
@@ -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<SubsonicSong[]>([]);
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);
}}
>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}>
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
@@ -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);
}}
>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}>
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
+4
View File
@@ -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));
};