diff --git a/src/locales/de.ts b/src/locales/de.ts index 5f8d4b63..8817fe69 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 347febf1..466f4ffd 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 6afc0073..1b08423f 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 415b886c..778201a5 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d6933382..40673c71 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3ee67f42..1191a22a 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 0160481b..4bdb576e 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1359,6 +1359,10 @@ export const ruTranslation = { titleBadge: 'Плейлист', refreshSuggestions: 'Обновить подборку', addSong: 'В плейлист', + preview: 'Превью (30 с)', + previewStop: 'Остановить превью', + playNextSuggestion: 'Играть следующим', + suggestionsHint: 'Двойной клик по строке — добавить в плейлист', addSelected: 'Добавить выбранные', cacheOffline: 'Сохранить плейлист офлайн', offlineCached: 'Плейлист сохранён', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 08b68423..b8303662 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1269,6 +1269,10 @@ export const zhTranslation = { titleBadge: '播放列表', refreshSuggestions: '新建议', addSong: '添加到播放列表', + preview: '试听 (30 秒)', + previewStop: '停止试听', + playNextSuggestion: '播放下一首', + suggestionsHint: '双击行可添加到播放列表', addSelected: '添加所选', cacheOffline: '离线缓存播放列表', offlineCached: '播放列表已缓存', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index ec2bdca5..dc7da965 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); + const [previewingId, setPreviewingId] = useState(null); + const previewAudioRef = useRef(null); + const previewTimerRef = useRef(null); + const previewResumeRef = useRef(false); const [contextMenuSongId, setContextMenuSongId] = useState(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(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 ── */}
-

{t('playlists.suggestions')}

+
+

{t('playlists.suggestions')}

+ {t('playlists.suggestionsHint')} +
+ + {song.title} +
+ ); case 'artist': return (
{ if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} diff --git a/src/styles/components.css b/src/styles/components.css index 8e7c9a66..875ebc9e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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;