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',
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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -1359,6 +1359,10 @@ export const ruTranslation = {
titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку',
addSong: 'В плейлист',
preview: 'Превью (30 с)',
previewStop: 'Остановить превью',
playNextSuggestion: 'Играть следующим',
suggestionsHint: 'Двойной клик по строке — добавить в плейлист',
addSelected: 'Добавить выбранные',
cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён',
+4
View File
@@ -1269,6 +1269,10 @@ export const zhTranslation = {
titleBadge: '播放列表',
refreshSuggestions: '新建议',
addSong: '添加到播放列表',
preview: '试听 (30 秒)',
previewStop: '停止试听',
playNextSuggestion: '播放下一首',
suggestionsHint: '双击行可添加到播放列表',
addSelected: '添加所选',
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',