mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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
@@ -1287,6 +1287,10 @@ export const deTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
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',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
|
||||
@@ -1293,6 +1293,10 @@ export const enTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
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',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
|
||||
@@ -1280,6 +1280,10 @@ export const esTranslation = {
|
||||
titleBadge: 'Lista',
|
||||
refreshSuggestions: 'Nuevas sugerencias',
|
||||
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',
|
||||
cacheOffline: 'Guardar lista offline',
|
||||
offlineCached: 'Lista guardada',
|
||||
|
||||
@@ -1275,6 +1275,10 @@ export const frTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
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',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
|
||||
@@ -1274,6 +1274,10 @@ export const nbTranslation = {
|
||||
titleBadge: 'Spilleliste',
|
||||
refreshSuggestions: 'Nye forslag',
|
||||
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',
|
||||
cacheOffline: 'Bufre spilleliste offline',
|
||||
offlineCached: 'Spilleliste bufret',
|
||||
|
||||
@@ -1274,6 +1274,10 @@ export const nlTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
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',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
|
||||
@@ -1359,6 +1359,10 @@ export const ruTranslation = {
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'В плейлист',
|
||||
preview: 'Превью (30 с)',
|
||||
previewStop: 'Остановить превью',
|
||||
playNextSuggestion: 'Играть следующим',
|
||||
suggestionsHint: 'Двойной клик по строке — добавить в плейлист',
|
||||
addSelected: 'Добавить выбранные',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
|
||||
@@ -1269,6 +1269,10 @@ export const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
preview: '试听 (30 秒)',
|
||||
previewStop: '停止试听',
|
||||
playNextSuggestion: '播放下一首',
|
||||
suggestionsHint: '双击行可添加到播放列表',
|
||||
addSelected: '添加所选',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1668,6 +1668,111 @@
|
||||
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.context-active {
|
||||
background: var(--bg-hover);
|
||||
@@ -7884,6 +7989,19 @@ html.no-compositing .fs-seekbar-played {
|
||||
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-item--submenu {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user