mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(playlists): suggestion-row preview UX (#365)
* feat(playlists): suggestion-row preview UX (30s preview, double-click play-next, scroll keep)
Reworks the interaction on the suggestion rows below a playlist so users
can audition songs before deciding what to do with them.
- New "Preview" pill button left of each track title plays a 30-second
mid-song sample via a parallel HTML5 audio element. The main player
pauses on the first preview and auto-resumes when the preview ends.
Switching previews chains without re-resuming. External main-player
playback (spacebar, mediakey) cancels the preview without resuming.
Animated underline shows the 30 s progress.
- Double-click the row to insert the song at queueIndex + 1 and skip to
it ("Play next"). Single-click is intentionally inert so a stray click
next to the preview button no longer drops a song into the queue by
accident. Tooltip on the row makes the affordance discoverable.
- The + button still adds to the playlist, and now restores the
.main-content scroll position so the page no longer jumps to the top
after pressing it.
- i18n keys in all 8 locales: playlists.preview / previewStop / previewShort
/ previewStopShort / suggestionDoubleClickPlayNext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playlists): WebKitGTK NULL-instance crash on preview teardown
cucadmuh hit GLib-GObject-CRITICAL: invalid (NULL) pointer instance /
g_signal_connect_data assertion failed, with the UI freezing after
clicking a preview button. Root cause was the audio-element lifecycle:
- `audio.src = ''` to "stop" leaves WebKitGTK's GStreamer playbin in a
half-initialized state. The next signal_connect dereferences NULL.
- Creating `new Audio()` per click stacked half-torn-down playbins
during rapid switches.
- `loadedmetadata` listeners on orphaned audio elements could call
play() on an already-discarded instance.
Fix: reuse one <audio> element per component, tear down the source
via removeAttribute('src') + load() (which resets the playbin
cleanly), and gate async listeners behind a session counter so stale
metadata events on switched-away previews are ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(playlists): icon-based action buttons + add-on-doubleclick + hint
- Replace text-pill Preview button with circular icon button + animated
SVG progress ring (Play/Square icon swap when active).
- New Play-next button (filled accent circle, white triangle) sits
left of the Preview button — explicit affordance for the action that
used to be a hidden double-click.
- Double-click on a suggestion row now triggers Add-to-playlist (the
same action as the + button on the right) — discoverable shortcut
for mouse users.
- Subtitle under the Suggested Songs header announces the add-on-
doubleclick affordance, in all 8 locales.
- Drop the now-obsolete previewShort / previewStopShort short-label
keys (text replaced by icons); rename suggestionDoubleClickPlayNext
→ playNextSuggestion to match its new role on the Play-next button.
- Keep the session-counter guard from the prior commit so async
loadedmetadata handlers from a switched-away preview can't play()
on a discarded element.
Note: header column labels visually drift from the data columns when
suggestion rows have the action buttons; left as-is for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playlists): apply LUFS pre-analysis attenuation to suggestion previews
When the main player is on loudness normalization, analysed tracks come
out reduced toward target while the unanalysed preview <audio> blasts
the file at its natural level — cucadmuh reported the previews are
audibly louder than the playlist playback.
Apply the user's stored pre-analysis attenuation (the slider value, not
the target-offset effective form) as a linear gain on the preview's
audio.volume. Default −4.5 dB lands the preview at ~60% of the player
volume, which roughly tracks how aggressively the Rust engine pulls
naturally-loud tracks toward target.
If the engine is off, behavior is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(playlists): use chevron icon for suggestion preview button
Distinguishes the preview button from the adjacent play-next button
by mirroring the Play Next chevron from the context menu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
70ff025ece
commit
2bea55bedd
@@ -1,13 +1,13 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, buildDownloadUrl, buildStreamUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -292,6 +292,10 @@ export default function PlaylistDetail() {
|
||||
const [sortClickCount, setSortClickCount] = useState(0);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewTimerRef = useRef<number | null>(null);
|
||||
const previewResumeRef = useRef<boolean>(false);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const zipDownloads = useZipDownloadStore(s => s.downloads);
|
||||
@@ -916,14 +920,111 @@ export default function PlaylistDetail() {
|
||||
// ── Add ───────────────────────────────────────────────────────
|
||||
const addSong = (song: SubsonicSong) => {
|
||||
if (songs.some(s => s.id === song.id)) return;
|
||||
const scrollHost = document.querySelector('.main-content') as HTMLElement | null;
|
||||
const savedScroll = scrollHost?.scrollTop ?? 0;
|
||||
const next = [...songs, song];
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
setSuggestions(prev => prev.filter(s => s.id !== song.id));
|
||||
setSearchResults(prev => prev.filter(s => s.id !== song.id));
|
||||
if (scrollHost) {
|
||||
requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; });
|
||||
}
|
||||
showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name }));
|
||||
};
|
||||
|
||||
// ── Preview (30s mid-song sample via parallel HTML5 audio) ────
|
||||
// Session counter invalidates `loadedmetadata`/`error` callbacks
|
||||
// bound to a previous preview that the user has already switched
|
||||
// away from — without it, a slow-to-load preview can `play()` on a
|
||||
// discarded element while the new one is still loading.
|
||||
const previewSessionRef = useRef<number>(0);
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
previewSessionRef.current++;
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
if (previewTimerRef.current !== null) {
|
||||
clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = null;
|
||||
}
|
||||
if (previewResumeRef.current) {
|
||||
previewResumeRef.current = false;
|
||||
usePlayerStore.getState().resume();
|
||||
}
|
||||
setPreviewingId(null);
|
||||
}, []);
|
||||
|
||||
const startPreview = useCallback((song: SubsonicSong) => {
|
||||
if (previewingId === song.id) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
// Tear down audio + timer but keep the resume flag so the main
|
||||
// player only resumes after the *last* preview ends.
|
||||
previewSessionRef.current++;
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
if (previewTimerRef.current !== null) {
|
||||
clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = null;
|
||||
}
|
||||
if (!previewResumeRef.current && usePlayerStore.getState().isPlaying) {
|
||||
usePlayerStore.getState().pause();
|
||||
previewResumeRef.current = true;
|
||||
}
|
||||
const session = ++previewSessionRef.current;
|
||||
const audio = new Audio();
|
||||
// Match the rough loudness compensation the main Rust player applies,
|
||||
// otherwise unanalysed previews blast out at full natural level
|
||||
// while the main player serves cache-corrected tracks.
|
||||
const baseVolume = usePlayerStore.getState().volume;
|
||||
const auth = useAuthStore.getState();
|
||||
const attenuationDb = auth.normalizationEngine === 'loudness'
|
||||
? Math.min(0, auth.loudnessPreAnalysisAttenuationDb)
|
||||
: 0;
|
||||
audio.volume = Math.max(0, Math.min(1, baseVolume * Math.pow(10, attenuationDb / 20)));
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (previewSessionRef.current !== session) return;
|
||||
const dur = audio.duration && Number.isFinite(audio.duration)
|
||||
? audio.duration
|
||||
: (song.duration ?? 0);
|
||||
audio.currentTime = Math.max(0, dur * 0.33);
|
||||
audio.play().catch(() => {
|
||||
if (previewSessionRef.current === session) stopPreview();
|
||||
});
|
||||
}, { once: true });
|
||||
audio.addEventListener('error', () => {
|
||||
if (previewSessionRef.current === session) stopPreview();
|
||||
}, { once: true });
|
||||
audio.src = buildStreamUrl(song.id);
|
||||
previewAudioRef.current = audio;
|
||||
setPreviewingId(song.id);
|
||||
previewTimerRef.current = window.setTimeout(stopPreview, 30000);
|
||||
}, [previewingId, stopPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.isPlaying && !prev.isPlaying && previewAudioRef.current) {
|
||||
// Main playback resumed externally (spacebar, mediakey, suggestion-row click).
|
||||
// Cancel the preview without resuming again.
|
||||
previewResumeRef.current = false;
|
||||
stopPreview();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
stopPreview();
|
||||
};
|
||||
}, [stopPreview]);
|
||||
|
||||
// ── Rating / Star ─────────────────────────────────────────────
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||
@@ -1717,7 +1818,10 @@ export default function PlaylistDetail() {
|
||||
{/* ── Suggestions ── */}
|
||||
<div className="playlist-suggestions tracklist">
|
||||
<div className="playlist-suggestions-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
|
||||
<div className="playlist-suggestions-title">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
|
||||
<span className="playlist-suggestions-hint">{t('playlists.suggestionsHint')}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => loadSuggestions(songs)}
|
||||
@@ -1755,7 +1859,7 @@ export default function PlaylistDetail() {
|
||||
style={gridStyle}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onClick={e => {
|
||||
onDoubleClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addSong(song);
|
||||
}}
|
||||
@@ -1768,7 +1872,48 @@ export default function PlaylistDetail() {
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'title': return (
|
||||
<div key="title" className="track-info track-info-suggestion">
|
||||
<button
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
|
||||
const track = songToTrack(song);
|
||||
if (!currentTrack || queue.length === 0) {
|
||||
playTrack(track, [track]);
|
||||
return;
|
||||
}
|
||||
const insertAt = Math.min(queueIndex + 1, queue.length);
|
||||
const newQueue = [
|
||||
...queue.slice(0, insertAt),
|
||||
track,
|
||||
...queue.slice(insertAt),
|
||||
];
|
||||
playTrack(track, newQueue);
|
||||
}}
|
||||
data-tooltip={t('playlists.playNextSuggestion')}
|
||||
aria-label={t('playlists.playNextSuggestion')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); startPreview(song); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
|
||||
|
||||
Reference in New Issue
Block a user