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.
This commit is contained in:
Lukáš Lipinský
2026-09-05 11:36:54 +02:00
committed by Tobias Gesellchen
parent 96b1de2163
commit aa16d1040b
4 changed files with 167 additions and 10 deletions
@@ -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, `<!doctype html>
<html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="importmap">{"imports":{"preact":"/app/static/lib/preact.module.js","preact/hooks":"/app/static/lib/preact-hooks.module.js","htm":"/app/static/lib/htm.module.js"}}</script>
<link rel="stylesheet" href="/app/static/css/app.css">
</head><body><div class="app"><div id="fixture"></div></div>
<script type="module">%s</script></body></html>`, 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")
}
})
}
}
+26 -5
View File
@@ -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 {
@@ -31,7 +31,7 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
return html`
<div class="device-card" onClick=${() => onSelect(id)}>
<div class="device-header">
<span class="device-name">${info?.name || id}</span>
<span class="device-name" title=${info?.name || id}>${info?.name || id}</span>
<span class="device-header-right">
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
${!stereoPair ? html`<button class="device-remove" title="Remove this device"
@@ -49,7 +49,7 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
` : null}
</div>
${!isStandby ? html`
<div class="now-playing-mini">
<div class="now-playing-mini" title=${[np.Track || np.StationName || np.Source, np.Artist].filter(Boolean).join(' - ')}>
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
${np.Artist ? html`<span class="artist-mini"> — ${np.Artist}</span>` : null}
@@ -101,6 +101,10 @@ export function NowPlaying({ nowPlaying, deviceId, presets }) {
}
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
const longMetadata = [title, nowPlaying.Artist, nowPlaying.Album]
.some(value => value && value.length > 80);
const isRAOP = nowPlaying.Source === 'AIRPLAY' || nowPlaying.Source === 'RAOP';
const showFullMetadata = isRAOP || longMetadata;
const artURL = nowPlaying.Art?.URL;
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
const total = nowPlaying.Time?.Total ?? 0;
@@ -110,13 +114,23 @@ export function NowPlaying({ nowPlaying, deviceId, presets }) {
<div class="now-playing">
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
<div class="track-info">
<div class="track-title">${title}</div>
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
<div class="track-title" title=${title}>${title}</div>
${nowPlaying.Artist && html`<div class="track-artist" title=${nowPlaying.Artist}>${nowPlaying.Artist}</div>`}
${nowPlaying.Album && html`<div class="track-album" title=${nowPlaying.Album}>${nowPlaying.Album}</div>`}
<div class="track-meta">
<span class="track-source">${nowPlaying.Source}</span>
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
</div>
${showFullMetadata && html`
<details class="track-details">
<summary>Full details</summary>
<dl>
<dt>Title</dt><dd>${title}</dd>
${nowPlaying.Artist && html`<dt>Artist</dt><dd>${nowPlaying.Artist}</dd>`}
${nowPlaying.Album && html`<dt>Album</dt><dd>${nowPlaying.Album}</dd>`}
</dl>
</details>
`}
${total > 0 && html`
<div class="progress-row">
<div class="progress-bar">