From aa16d1040b9ad7d4efaf122102eb8c85a9d6bf31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lipinsk=C3=BD?=
<6032558+Mr-Tao@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:54:21 +0200
Subject: [PATCH] Prevent long now-playing metadata overflow
Constrain device and playback metadata across narrow layouts while retaining complete values through tooltips and a touch-friendly details disclosure, including RAOP tracks.
---
.../soundtouchweb/browser_metadata_test.go | 122 ++++++++++++++++++
pkg/service/soundtouchweb/static/css/app.css | 31 ++++-
.../static/js/components/DeviceList.js | 4 +-
.../static/js/components/NowPlaying.js | 20 ++-
4 files changed, 167 insertions(+), 10 deletions(-)
create mode 100644 pkg/service/soundtouchweb/browser_metadata_test.go
diff --git a/pkg/service/soundtouchweb/browser_metadata_test.go b/pkg/service/soundtouchweb/browser_metadata_test.go
new file mode 100644
index 00000000..c77f04ba
--- /dev/null
+++ b/pkg/service/soundtouchweb/browser_metadata_test.go
@@ -0,0 +1,122 @@
+//go:build browsertest
+
+package soundtouchweb
+
+import (
+ "fmt"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/chromedp/chromedp"
+ "github.com/go-chi/chi/v5"
+)
+
+func newMetadataFixtureServer(t *testing.T, moduleScript string) *httptest.Server {
+ t.Helper()
+
+ r := chi.NewRouter()
+ staticFS, err := fs.Sub(StaticFS, "static")
+ if err != nil {
+ t.Fatalf("open static fixture filesystem: %v", err)
+ }
+ r.Get("/app/static/*", http.StripPrefix("/app/static", http.FileServer(http.FS(staticFS))).ServeHTTP)
+ r.Get("/fixture", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = fmt.Fprintf(w, `
+
+
+
+
+
+
+`, moduleScript)
+ })
+
+ server := httptest.NewServer(r)
+ t.Cleanup(server.Close)
+
+ return server
+}
+
+func TestNowPlayingLongTextDoesNotOverflow(t *testing.T) {
+ const overflowFixture = `
+import { h, render } from 'preact';
+import { NowPlaying } from '/app/static/js/components/NowPlaying.js';
+import { DeviceList } from '/app/static/js/components/DeviceList.js';
+const track = 'RAOP-' + 'VeryLongUnbrokenTrackValue'.repeat(30);
+const artist = 'Artist-' + 'UnbrokenArtistValue'.repeat(30);
+const album = 'Album-' + 'UnbrokenAlbumValue'.repeat(30);
+const deviceName = 'Speaker-' + 'UnbrokenDeviceName'.repeat(30);
+const nowPlaying = { Source: 'AIRPLAY', SourceAccount: '', Track: track, Artist: artist, Album: album, PlayStatus: 'PLAY_STATE' };
+const ordinaryTrack = 'A perfectly ordinary AirPlay title with forty characters';
+const ordinaryArtist = 'An ordinary AirPlay artist';
+const ordinaryAlbum = 'An ordinary AirPlay album title';
+const ordinaryNowPlaying = { Source: 'AIRPLAY', Track: ordinaryTrack, Artist: ordinaryArtist, Album: ordinaryAlbum, PlayStatus: 'PLAY_STATE' };
+const devices = { speaker: { info: { name: deviceName, type: 'SoundTouch' }, status: { isConnected: true, nowPlaying } } };
+render(h('div', {},
+ h('div', { class: 'device-detail' }, h(NowPlaying, { nowPlaying })),
+ h('div', { class: 'ordinary-raop' }, h(NowPlaying, { nowPlaying: ordinaryNowPlaying })),
+ h('div', { class: 'device-grid' }, h(DeviceList, { devices, onSelect() {}, onDiscover() {}, onRemove() {} }))
+), document.getElementById('fixture'));
+window.expectedTrack = track;
+window.expectedArtist = artist;
+window.expectedAlbum = album;
+window.expectedDeviceName = deviceName;
+window.expectedOrdinaryMetadata = [ordinaryTrack, ordinaryArtist, ordinaryAlbum];
+`
+ server := newMetadataFixtureServer(t, overflowFixture)
+ ctx := newHeadlessChromeContext(t)
+
+ for _, viewport := range []struct {
+ name string
+ width, height int64
+ }{
+ {name: "desktop", width: 1200, height: 800},
+ {name: "mobile", width: 320, height: 700},
+ } {
+ t.Run(viewport.name, func(t *testing.T) {
+ var overflow []string
+ var titlesComplete, detailsComplete, ordinaryDetailsComplete bool
+ if err := chromedp.Run(ctx,
+ chromedp.EmulateViewport(viewport.width, viewport.height),
+ chromedp.Navigate(server.URL+"/fixture"),
+ chromedp.WaitVisible(`.track-title`, chromedp.ByQuery),
+ chromedp.Evaluate(`[
+ ...[...document.querySelectorAll('.app, .device-detail, .now-playing, .track-info, .device-grid, .device-card')]
+ .filter(el => el.scrollWidth > el.clientWidth + 1)
+ .map(el => el.className),
+ ...(document.documentElement.scrollWidth > window.innerWidth + 1 ? ['document'] : [])
+]`, &overflow),
+ chromedp.Evaluate(`document.querySelector('.track-title').title === window.expectedTrack &&
+ document.querySelector('.track-artist').title === window.expectedArtist &&
+ document.querySelector('.track-album').title === window.expectedAlbum &&
+ document.querySelector('.device-name').title === window.expectedDeviceName &&
+ document.querySelector('.now-playing-mini').title.startsWith(window.expectedTrack)`, &titlesComplete),
+ chromedp.Click(`.track-details summary`, chromedp.ByQuery),
+ chromedp.Evaluate(`document.querySelector('.track-details').open &&
+ document.querySelector('.track-details').textContent.includes(window.expectedTrack) &&
+ document.querySelector('.track-details').textContent.includes(window.expectedArtist) &&
+ document.querySelector('.track-details').textContent.includes(window.expectedAlbum)`, &detailsComplete),
+ chromedp.Click(`.ordinary-raop .track-details summary`, chromedp.ByQuery),
+ chromedp.Evaluate(`document.querySelector('.ordinary-raop .track-details').open &&
+ window.expectedOrdinaryMetadata.every(value => document.querySelector('.ordinary-raop .track-details').textContent.includes(value))`, &ordinaryDetailsComplete),
+ ); err != nil {
+ t.Fatalf("measure now-playing layout: %v", err)
+ }
+ if len(overflow) != 0 {
+ t.Errorf("overflowing elements: %v", overflow)
+ }
+ if !titlesComplete {
+ t.Error("complete long values are not available through title attributes")
+ }
+ if !detailsComplete {
+ t.Error("complete long metadata is not available through the touch disclosure")
+ }
+ if !ordinaryDetailsComplete {
+ t.Error("ordinary-length RAOP metadata is not available through the touch disclosure")
+ }
+ })
+ }
+}
diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css
index 7847235f..7a29c8e8 100644
--- a/pkg/service/soundtouchweb/static/css/app.css
+++ b/pkg/service/soundtouchweb/static/css/app.css
@@ -365,11 +365,12 @@ img { display: block; max-width: 100%; }
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
+ min-width: 0;
}
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; gap: .5rem; }
-.device-name { font-weight: 600; font-size: .95rem; }
+.device-name { font-weight: 600; font-size: .95rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.device-header-right { display: flex; align-items: center; gap: .5rem; flex-shrink: 0; }
/* Quiet remove affordance: invisible until the card is hovered, then dim,
@@ -429,6 +430,8 @@ img { display: block; max-width: 100%; }
min-height: 98px; /* Fixed height to avoid jumps between tracks/standby */
align-items: center;
position: relative; /* anchor for the ★ fav button */
+ min-width: 0;
+ max-width: 100%;
}
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
@@ -484,13 +487,31 @@ img { display: block; max-width: 100%; }
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
-.track-info { flex: 1; overflow: hidden; }
-.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.track-info { flex: 1; min-width: 0; overflow: hidden; }
+.track-title {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ font-weight: 600;
+}
+.track-artist, .track-album {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
.track-album { font-size: .8rem; color: var(--text-dim); }
-.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
-.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
+.track-meta { display: flex; min-width: 0; align-items: center; gap: .5rem; margin-top: .25rem; }
+.track-source { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
+.track-details { margin-top: .35rem; font-size: .75rem; }
+.track-details summary { color: var(--text-dim); cursor: pointer; }
+.track-details dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .2rem .5rem; margin: .35rem 0 0; }
+.track-details dt { color: var(--text-dim); }
+.track-details dd { min-width: 0; margin: 0; overflow-wrap: anywhere; word-break: break-word; }
/* ── Transport controls ──────────────────────────────────────────────────── */
.controls {
diff --git a/pkg/service/soundtouchweb/static/js/components/DeviceList.js b/pkg/service/soundtouchweb/static/js/components/DeviceList.js
index 3cc7c40e..c2ad0e4f 100644
--- a/pkg/service/soundtouchweb/static/js/components/DeviceList.js
+++ b/pkg/service/soundtouchweb/static/js/components/DeviceList.js
@@ -31,7 +31,7 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
return html`
onSelect(id)}>