mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
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:
committed by
Tobias Gesellchen
parent
96b1de2163
commit
aa16d1040b
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user