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:
Frank Stellmacher
2026-04-29 23:02:31 +02:00
committed by GitHub
parent 70ff025ece
commit 2bea55bedd
10 changed files with 300 additions and 5 deletions
+4
View File
@@ -1287,6 +1287,10 @@ export const deTranslation = {
titleBadge: 'Playlist', titleBadge: 'Playlist',
refreshSuggestions: 'Neue Vorschläge', refreshSuggestions: 'Neue Vorschläge',
addSong: 'Zur Playlist hinzufügen', addSong: 'Zur Playlist hinzufügen',
preview: 'Vorschau (30 s)',
previewStop: 'Vorschau stoppen',
playNextSuggestion: 'Als Nächstes spielen',
suggestionsHint: 'Doppelklick auf eine Zeile fügt sie zur Playlist hinzu',
addSelected: 'Ausgewählte hinzufügen', addSelected: 'Ausgewählte hinzufügen',
cacheOffline: 'Playlist offline speichern', cacheOffline: 'Playlist offline speichern',
offlineCached: 'Playlist gecacht', offlineCached: 'Playlist gecacht',
+4
View File
@@ -1293,6 +1293,10 @@ export const enTranslation = {
titleBadge: 'Playlist', titleBadge: 'Playlist',
refreshSuggestions: 'New suggestions', refreshSuggestions: 'New suggestions',
addSong: 'Add to playlist', addSong: 'Add to playlist',
preview: 'Preview (30s)',
previewStop: 'Stop preview',
playNextSuggestion: 'Play next',
suggestionsHint: 'Double-click a row to add it to the playlist',
addSelected: 'Add selected', addSelected: 'Add selected',
cacheOffline: 'Cache playlist offline', cacheOffline: 'Cache playlist offline',
offlineCached: 'Playlist cached', offlineCached: 'Playlist cached',
+4
View File
@@ -1280,6 +1280,10 @@ export const esTranslation = {
titleBadge: 'Lista', titleBadge: 'Lista',
refreshSuggestions: 'Nuevas sugerencias', refreshSuggestions: 'Nuevas sugerencias',
addSong: 'Agregar a lista', addSong: 'Agregar a lista',
preview: 'Vista previa (30 s)',
previewStop: 'Detener vista previa',
playNextSuggestion: 'Reproducir a continuación',
suggestionsHint: 'Doble clic en una fila para añadirla a la lista',
addSelected: 'Agregar seleccionados', addSelected: 'Agregar seleccionados',
cacheOffline: 'Guardar lista offline', cacheOffline: 'Guardar lista offline',
offlineCached: 'Lista guardada', offlineCached: 'Lista guardada',
+4
View File
@@ -1275,6 +1275,10 @@ export const frTranslation = {
titleBadge: 'Playlist', titleBadge: 'Playlist',
refreshSuggestions: 'Nouvelles suggestions', refreshSuggestions: 'Nouvelles suggestions',
addSong: 'Ajouter à la playlist', addSong: 'Ajouter à la playlist',
preview: 'Aperçu (30 s)',
previewStop: 'Arrêter l\'aperçu',
playNextSuggestion: 'Lire ensuite',
suggestionsHint: "Double-cliquez sur une ligne pour l'ajouter à la playlist",
addSelected: 'Ajouter la sélection', addSelected: 'Ajouter la sélection',
cacheOffline: 'Mettre la playlist hors ligne', cacheOffline: 'Mettre la playlist hors ligne',
offlineCached: 'Playlist en cache', offlineCached: 'Playlist en cache',
+4
View File
@@ -1274,6 +1274,10 @@ export const nbTranslation = {
titleBadge: 'Spilleliste', titleBadge: 'Spilleliste',
refreshSuggestions: 'Nye forslag', refreshSuggestions: 'Nye forslag',
addSong: 'Legg til i spilleliste', addSong: 'Legg til i spilleliste',
preview: 'Forhåndsvisning (30 s)',
previewStop: 'Stopp forhåndsvisning',
playNextSuggestion: 'Spill som neste',
suggestionsHint: 'Dobbeltklikk på en rad for å legge den til i spillelisten',
addSelected: 'Legg til valgte', addSelected: 'Legg til valgte',
cacheOffline: 'Bufre spilleliste offline', cacheOffline: 'Bufre spilleliste offline',
offlineCached: 'Spilleliste bufret', offlineCached: 'Spilleliste bufret',
+4
View File
@@ -1274,6 +1274,10 @@ export const nlTranslation = {
titleBadge: 'Playlist', titleBadge: 'Playlist',
refreshSuggestions: 'Nieuwe suggesties', refreshSuggestions: 'Nieuwe suggesties',
addSong: 'Toevoegen aan playlist', addSong: 'Toevoegen aan playlist',
preview: 'Voorbeeld (30 s)',
previewStop: 'Voorbeeld stoppen',
playNextSuggestion: 'Hierna afspelen',
suggestionsHint: 'Dubbelklik op een rij om hem aan de afspeellijst toe te voegen',
addSelected: 'Geselecteerde toevoegen', addSelected: 'Geselecteerde toevoegen',
cacheOffline: 'Playlist offline opslaan', cacheOffline: 'Playlist offline opslaan',
offlineCached: 'Playlist gecached', offlineCached: 'Playlist gecached',
+4
View File
@@ -1359,6 +1359,10 @@ export const ruTranslation = {
titleBadge: 'Плейлист', titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку', refreshSuggestions: 'Обновить подборку',
addSong: 'В плейлист', addSong: 'В плейлист',
preview: 'Превью (30 с)',
previewStop: 'Остановить превью',
playNextSuggestion: 'Играть следующим',
suggestionsHint: 'Двойной клик по строке — добавить в плейлист',
addSelected: 'Добавить выбранные', addSelected: 'Добавить выбранные',
cacheOffline: 'Сохранить плейлист офлайн', cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён', offlineCached: 'Плейлист сохранён',
+4
View File
@@ -1269,6 +1269,10 @@ export const zhTranslation = {
titleBadge: '播放列表', titleBadge: '播放列表',
refreshSuggestions: '新建议', refreshSuggestions: '新建议',
addSong: '添加到播放列表', addSong: '添加到播放列表',
preview: '试听 (30 秒)',
previewStop: '停止试听',
playNextSuggestion: '播放下一首',
suggestionsHint: '双击行可添加到播放列表',
addSelected: '添加所选', addSelected: '添加所选',
cacheOffline: '离线缓存播放列表', cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存', offlineCached: '播放列表已缓存',
+149 -4
View File
@@ -1,13 +1,13 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams, useNavigate, useLocation } from 'react-router-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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { import {
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
search, setRating, star, unstar, search, setRating, star, unstar,
getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong, getRandomSongs, buildDownloadUrl, buildStreamUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic'; } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -292,6 +292,10 @@ export default function PlaylistDetail() {
const [sortClickCount, setSortClickCount] = useState(0); const [sortClickCount, setSortClickCount] = useState(0);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set()); const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null); 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 [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const zipDownloads = useZipDownloadStore(s => s.downloads); const zipDownloads = useZipDownloadStore(s => s.downloads);
@@ -916,14 +920,111 @@ export default function PlaylistDetail() {
// ── Add ─────────────────────────────────────────────────────── // ── Add ───────────────────────────────────────────────────────
const addSong = (song: SubsonicSong) => { const addSong = (song: SubsonicSong) => {
if (songs.some(s => s.id === song.id)) return; 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]; const next = [...songs, song];
setSongs(next); setSongs(next);
savePlaylist(next); savePlaylist(next);
setSuggestions(prev => prev.filter(s => s.id !== song.id)); setSuggestions(prev => prev.filter(s => s.id !== song.id));
setSearchResults(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 })); 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 ───────────────────────────────────────────── // ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => { const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating })); setRatings(prev => ({ ...prev, [songId]: rating }));
@@ -1717,7 +1818,10 @@ export default function PlaylistDetail() {
{/* ── Suggestions ── */} {/* ── Suggestions ── */}
<div className="playlist-suggestions tracklist"> <div className="playlist-suggestions tracklist">
<div className="playlist-suggestions-header"> <div className="playlist-suggestions-header">
<div className="playlist-suggestions-title">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2> <h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<span className="playlist-suggestions-hint">{t('playlists.suggestionsHint')}</span>
</div>
<button <button
className="btn btn-surface" className="btn btn-surface"
onClick={() => loadSuggestions(songs)} onClick={() => loadSuggestions(songs)}
@@ -1755,7 +1859,7 @@ export default function PlaylistDetail() {
style={gridStyle} style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)} onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)} onMouseLeave={() => setHoveredSuggestionId(null)}
onClick={e => { onDoubleClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return; if ((e.target as HTMLElement).closest('button, a, input')) return;
addSong(song); addSong(song);
}} }}
@@ -1768,7 +1872,48 @@ export default function PlaylistDetail() {
{visibleCols.map(colDef => { {visibleCols.map(colDef => {
switch (colDef.key) { switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>; 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 ( case 'artist': return (
<div key="artist" className="track-artist-cell"> <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> <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>
+118
View File
@@ -1668,6 +1668,111 @@
color: var(--accent); color: var(--accent);
} }
/* Suggestion-row action buttons — sit left of the track title */
.track-info-suggestion {
display: flex;
align-items: center;
gap: 6px;
}
.playlist-suggestion-play-btn {
flex: 0 0 auto;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--accent);
color: var(--ctp-crust);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.85;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.playlist-suggestion-play-btn:hover {
opacity: 1;
transform: scale(1.06);
}
.playlist-suggestion-play-icon {
display: block;
/* optical center for right-weighted triangle */
transform: translateX(0.5px);
}
.playlist-suggestion-preview-btn {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity var(--transition-fast), color var(--transition-fast);
}
.playlist-suggestions .track-row:hover .playlist-suggestion-preview-btn,
.playlist-suggestion-preview-btn:hover,
.playlist-suggestion-preview-btn.is-previewing {
opacity: 1;
color: var(--accent);
}
.playlist-suggestion-preview-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
transform: rotate(-90deg);
}
.playlist-suggestion-preview-ring-track {
fill: none;
stroke: var(--ctp-overlay0);
stroke-width: 1.5;
}
.playlist-suggestion-preview-ring-progress {
fill: none;
stroke: var(--accent);
stroke-width: 1.5;
/* circumference = 2π × 10.5 ≈ 65.97 */
stroke-dasharray: 65.97;
stroke-dashoffset: 65.97;
stroke-linecap: round;
}
.playlist-suggestion-preview-icon {
display: block;
position: relative;
z-index: 1;
}
/* Play triangle is right-weighted; nudge for optical center */
.playlist-suggestion-preview-icon-play {
transform: translateX(0.5px);
}
.playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress {
animation: playlist-preview-progress 30s linear forwards;
}
@keyframes playlist-preview-progress {
from { stroke-dashoffset: 65.97; }
to { stroke-dashoffset: 0; }
}
.track-row:hover, .track-row:hover,
.track-row.context-active { .track-row.context-active {
background: var(--bg-hover); background: var(--bg-hover);
@@ -7884,6 +7989,19 @@ html.no-compositing .fs-seekbar-played {
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
.playlist-suggestions-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.playlist-suggestions-hint {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
/* ─ Context Menu Submenu ─ */ /* ─ Context Menu Submenu ─ */
.context-menu-item--submenu { .context-menu-item--submenu {
position: relative; position: relative;