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)}>
- ${info?.name || id} + ${info?.name || id} ${!stereoPair ? html`