Skip to content

Commit f2181cc

Browse files
feat: dynamic lyrics layout reflow with content-based width measurement
Measure the widest lyrics line after render and constrain the lyrics panel to that width. The outer justify-center then distributes surplus space evenly on both sides, eliminating the asymmetric dead zone. - Add _measureLyricsWidth() using max-content offsetWidth measurement - Add ResizeObserver on lyrics-layout to re-measure on viewport resize (font uses clamp/2vw so line widths change with viewport) - Reset _lyricsContentWidth when lyrics are cleared - Remove w-full / max-w-5xl from inner container; bind width:100% only before measurement completes - Switch lyrics wrapper from static flex-1 to conditional shrink class with explicit pixel width after measurement - Add Playwright E2E tests covering Trail of Dead (short lines), Beck (long lines), Against Me! instrumental (no lyrics), viewport resize, and CSS class/style transitions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7e5533 commit f2181cc

4 files changed

Lines changed: 351 additions & 27 deletions

File tree

app/frontend/js/components/now-playing-view.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export function createNowPlayingView(Alpine) {
1010
lyricsLoading: false,
1111
_lyricsTrackKey: null,
1212
_lyricsFetchId: 0,
13+
_lyricsScrollTop: 0,
14+
_lyricsCanScrollMore: false,
15+
_lyricsContentWidth: null,
16+
_lyricsLayoutObserver: null,
1317

1418
// Virtual scroll state
1519
_rowHeight: 41,
@@ -41,6 +45,10 @@ export function createNowPlayingView(Alpine) {
4145
this._resizeObserver.disconnect();
4246
this._resizeObserver = null;
4347
}
48+
if (this._lyricsLayoutObserver) {
49+
this._lyricsLayoutObserver.disconnect();
50+
this._lyricsLayoutObserver = null;
51+
}
4452
if (this._rafId) {
4553
cancelAnimationFrame(this._rafId);
4654
this._rafId = null;
@@ -69,6 +77,7 @@ export function createNowPlayingView(Alpine) {
6977
async _fetchLyrics(track) {
7078
const fetchId = ++this._lyricsFetchId;
7179
this.lyrics = null;
80+
this._lyricsContentWidth = null;
7281
this.lyricsLoading = true;
7382

7483
try {
@@ -85,6 +94,7 @@ export function createNowPlayingView(Alpine) {
8594

8695
if (result && result.plain_lyrics) {
8796
this.lyrics = result.plain_lyrics;
97+
this.$nextTick(() => this._updateLyricsScrollState());
8898
} else {
8999
this.lyrics = null;
90100
}
@@ -99,6 +109,44 @@ export function createNowPlayingView(Alpine) {
99109
}
100110
},
101111

112+
_onLyricsScroll(event) {
113+
const el = event.target;
114+
this._lyricsScrollTop = el.scrollTop;
115+
this._lyricsCanScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 10;
116+
},
117+
118+
_updateLyricsScrollState() {
119+
const panel = this.$refs.lyricsPanel;
120+
if (!panel) return;
121+
panel.scrollTop = 0;
122+
this._lyricsScrollTop = 0;
123+
this._lyricsCanScrollMore = panel.scrollHeight > panel.clientHeight + 10;
124+
this._measureLyricsWidth();
125+
this._observeLyricsLayout();
126+
},
127+
128+
_measureLyricsWidth() {
129+
const panel = this.$refs.lyricsPanel;
130+
if (!panel) return;
131+
const p = panel.querySelector('[data-testid="lyrics-text"]');
132+
if (!p) return;
133+
const prev = p.style.width;
134+
p.style.width = 'max-content';
135+
// Add padding (pr-2 = 8px) to the measured text width
136+
this._lyricsContentWidth = p.offsetWidth + 8;
137+
p.style.width = prev;
138+
},
139+
140+
_observeLyricsLayout() {
141+
if (this._lyricsLayoutObserver) return;
142+
const layout = this.$el.querySelector('[data-testid="lyrics-layout"]');
143+
if (!layout) return;
144+
this._lyricsLayoutObserver = new ResizeObserver(() => {
145+
if (this.lyrics) this._measureLyricsWidth();
146+
});
147+
this._lyricsLayoutObserver.observe(layout);
148+
},
149+
102150
_onScroll() {
103151
if (this._rafId) return;
104152
this._rafId = requestAnimationFrame(() => {

app/frontend/styles.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,14 @@ aside button:hover svg {
643643
overflow-x: clip;
644644
}
645645

646+
/* Hide scrollbar on lyrics panel — scroll indicators replace it */
647+
.lyrics-hide-scrollbar {
648+
scrollbar-width: none;
649+
}
650+
.lyrics-hide-scrollbar::-webkit-scrollbar {
651+
display: none;
652+
}
653+
646654
/* Disable header clicks during column resize */
647655
.resizing-columns > div {
648656
pointer-events: none;
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForAlpine } from './fixtures/helpers.js';
3+
import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js';
4+
5+
// Real lyrics from LRCLIB cache -- short lines, narrower content width
6+
const TRAIL_OF_DEAD_LYRICS =
7+
`If i could make a list\nI'd put your name on top\nOf my mistakes and regrets\nAnd every line after it\nBecause every inch of hope\n\nBecomes a world of shame\nI've had to walk through\nEach and every day\n\nAt the top of my lungs\nIt would never return\nAnd if i screamed, "you were wrong"\nAll the faith that i've lost\n\nAnd there is nothing left to say\nThat has not been said\nIf i shout, you wouldn't listen\nI don't think it'd even sink in\nIf i could make a list\n\nOf my mistakes and regrets\nI'd put your name on top\nAnd every line after it\n\nBecomes a world of shame\nBecause every inch of hope\nI've had to walk through\nEach and every day\n\nAnd there is nothing left to say\nIf i shout, you wouldn't listen\nThat has not been said\n\nI don't think it'd even sink in\nIf you forget how to feel\nReach inside your chest\nIs there a heart beating?\nIs it just emptiness? (repeat)`;
8+
9+
// Real lyrics from LRCLIB cache -- long lines, wider content width
10+
const BECK_LYRICS =
11+
`In a cast iron cage you couldn't help but stare like a creature\nWith the laws of a brothel and the fireproof bones of a preacher\nAnd your lingo coined from the sacrament of a casino\nOn a government loan with a guillotine in your libido\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nThese profanity prayers\n\nWell you know how it looks when you pull all your books from the table\nAnd you stare into space trying to discern what to say now\nAnd you wait at the light and watch for a sign that you're breathing\n'Cause you can't just live on air and float to the ceiling\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nProfanity prayers\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nProfanity prayers`;
12+
13+
const TRAIL_OF_DEAD_TRACK = {
14+
id: 1,
15+
title: 'Mistakes & Regrets',
16+
artist: '...And You Will Know Us by the Trail of Dead',
17+
album: 'Source Tags & Codes',
18+
duration: 214000,
19+
filepath: '/music/trail-of-dead/mistakes-and-regrets.mp3',
20+
};
21+
22+
const BECK_TRACK = {
23+
id: 2,
24+
title: 'Profanity Prayers',
25+
artist: 'Beck',
26+
album: 'Modern Guilt',
27+
duration: 209000,
28+
filepath: '/music/beck/profanity-prayers.mp3',
29+
};
30+
31+
const INSTRUMENTAL_TRACK = {
32+
id: 3,
33+
title: 'A Brief Yet Triumphant Intermission',
34+
artist: 'Against Me!',
35+
album: 'Searching for a Former Clarity',
36+
duration: 47000,
37+
filepath: '/music/against-me/a-brief-yet-triumphant-intermission.mp3',
38+
};
39+
40+
/**
41+
* Navigate to Now Playing view and wait for it
42+
* @param {import('@playwright/test').Page} page
43+
*/
44+
async function navigateToNowPlaying(page) {
45+
await page.evaluate(() => {
46+
window.Alpine.store('ui').view = 'nowPlaying';
47+
});
48+
await page.waitForSelector('[x-data="nowPlayingView"]', { state: 'visible' });
49+
}
50+
51+
/**
52+
* Set current track and artwork on the player store
53+
* @param {import('@playwright/test').Page} page
54+
* @param {Object} track
55+
*/
56+
async function setCurrentTrack(page, track) {
57+
await page.evaluate((trackData) => {
58+
window.Alpine.store('player').currentTrack = trackData;
59+
window.Alpine.store('player').artwork = {
60+
data:
61+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
62+
mime_type: 'image/png',
63+
source: 'test',
64+
};
65+
}, track);
66+
}
67+
68+
/**
69+
* Inject lyrics directly into the nowPlayingView Alpine component and
70+
* trigger the measurement pipeline.
71+
* @param {import('@playwright/test').Page} page
72+
* @param {string|null} lyricsText
73+
*/
74+
async function setLyrics(page, lyricsText) {
75+
await page.evaluate((text) => {
76+
const el = document.querySelector('[x-data="nowPlayingView"]');
77+
const data = window.Alpine.$data(el);
78+
data.lyrics = text;
79+
if (text) {
80+
data.$nextTick(() => data._updateLyricsScrollState());
81+
} else {
82+
data._lyricsContentWidth = null;
83+
}
84+
}, lyricsText);
85+
}
86+
87+
/**
88+
* Wait for _lyricsContentWidth to be set (non-null)
89+
* @param {import('@playwright/test').Page} page
90+
*/
91+
async function waitForMeasurement(page) {
92+
await page.waitForFunction(
93+
() => {
94+
const el = document.querySelector('[x-data="nowPlayingView"]');
95+
return window.Alpine.$data(el)._lyricsContentWidth !== null;
96+
},
97+
null,
98+
{ timeout: 5000 },
99+
);
100+
}
101+
102+
/**
103+
* Get the current _lyricsContentWidth value
104+
* @param {import('@playwright/test').Page} page
105+
* @returns {Promise<number|null>}
106+
*/
107+
async function getLyricsContentWidth(page) {
108+
return await page.evaluate(() => {
109+
const el = document.querySelector('[x-data="nowPlayingView"]');
110+
return window.Alpine.$data(el)._lyricsContentWidth;
111+
});
112+
}
113+
114+
test.describe('Lyrics Layout Reflow', () => {
115+
test.beforeEach(async ({ page }) => {
116+
const libraryState = createLibraryState();
117+
await setupLibraryMocks(page, libraryState);
118+
await page.goto('/');
119+
await waitForAlpine(page);
120+
await page.waitForSelector('[x-data="libraryBrowser"]', { state: 'visible' });
121+
});
122+
123+
test('short lyrics (Trail of Dead) should size wrapper to content width', async ({ page }) => {
124+
await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK);
125+
await navigateToNowPlaying(page);
126+
127+
// Before lyrics: layout should not be visible
128+
await expect(page.locator('[data-testid="lyrics-layout"]')).not.toBeVisible();
129+
130+
await setLyrics(page, TRAIL_OF_DEAD_LYRICS);
131+
await expect(page.locator('[data-testid="lyrics-text"]')).toBeVisible();
132+
await waitForMeasurement(page);
133+
134+
// Wrapper should have explicit pixel width
135+
const lyricsWrapper = page.locator(
136+
'[data-testid="lyrics-layout"] > div > div:nth-child(2)',
137+
);
138+
const style = await lyricsWrapper.getAttribute('style');
139+
expect(style).toMatch(/width:\s*\d+(\.\d+)?px/);
140+
141+
const width = await getLyricsContentWidth(page);
142+
expect(width).toBeGreaterThan(0);
143+
expect(width).toBeLessThan(1624);
144+
});
145+
146+
test('long lyrics (Beck) should produce wider content width than short lyrics', async ({ page }) => {
147+
await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK);
148+
await navigateToNowPlaying(page);
149+
150+
// Measure short lyrics (Trail of Dead)
151+
await setLyrics(page, TRAIL_OF_DEAD_LYRICS);
152+
await waitForMeasurement(page);
153+
const shortWidth = await getLyricsContentWidth(page);
154+
155+
// Switch to long lyrics (Beck)
156+
await setCurrentTrack(page, BECK_TRACK);
157+
await setLyrics(page, BECK_LYRICS);
158+
await waitForMeasurement(page);
159+
const longWidth = await getLyricsContentWidth(page);
160+
161+
// Beck's longer lines should produce a wider measurement
162+
expect(longWidth).toBeGreaterThan(shortWidth);
163+
});
164+
165+
test('instrumental track (no lyrics) should not set content width', async ({ page }) => {
166+
await setCurrentTrack(page, INSTRUMENTAL_TRACK);
167+
await navigateToNowPlaying(page);
168+
169+
// Simulate lyrics not found (null)
170+
await setLyrics(page, null);
171+
172+
const width = await getLyricsContentWidth(page);
173+
expect(width).toBeNull();
174+
175+
// Lyrics layout should not be visible
176+
await expect(page.locator('[data-testid="lyrics-layout"]')).not.toBeVisible();
177+
});
178+
179+
test('switching from lyrics to instrumental should reset layout', async ({ page }) => {
180+
await setCurrentTrack(page, BECK_TRACK);
181+
await navigateToNowPlaying(page);
182+
183+
// Show lyrics first
184+
await setLyrics(page, BECK_LYRICS);
185+
await waitForMeasurement(page);
186+
const widthWithLyrics = await getLyricsContentWidth(page);
187+
expect(widthWithLyrics).toBeGreaterThan(0);
188+
189+
// Switch to instrumental (lyrics not found)
190+
await setCurrentTrack(page, INSTRUMENTAL_TRACK);
191+
await setLyrics(page, null);
192+
193+
const widthAfterClear = await getLyricsContentWidth(page);
194+
expect(widthAfterClear).toBeNull();
195+
});
196+
197+
test('lyrics panel width should change on viewport resize', async ({ page }) => {
198+
await setCurrentTrack(page, BECK_TRACK);
199+
await navigateToNowPlaying(page);
200+
await setLyrics(page, BECK_LYRICS);
201+
await waitForMeasurement(page);
202+
203+
const initialWidth = await getLyricsContentWidth(page);
204+
205+
// Shrink viewport -- font uses clamp(1rem, 2vw, 1.5rem) so text width changes
206+
await page.setViewportSize({ width: 900, height: 700 });
207+
208+
await page.waitForFunction(
209+
(prev) => {
210+
const el = document.querySelector('[x-data="nowPlayingView"]');
211+
const current = window.Alpine.$data(el)._lyricsContentWidth;
212+
return current !== null && current !== prev;
213+
},
214+
initialWidth,
215+
{ timeout: 5000 },
216+
);
217+
218+
const newWidth = await getLyricsContentWidth(page);
219+
expect(newWidth).not.toBe(initialWidth);
220+
expect(newWidth).toBeGreaterThan(0);
221+
});
222+
223+
test('inner container should drop width:100% after measurement', async ({ page }) => {
224+
await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK);
225+
await navigateToNowPlaying(page);
226+
await setLyrics(page, TRAIL_OF_DEAD_LYRICS);
227+
await waitForMeasurement(page);
228+
229+
const innerContainer = page.locator('[data-testid="lyrics-layout"] > div');
230+
const style = await innerContainer.getAttribute('style');
231+
expect(style || '').not.toContain('width: 100%');
232+
});
233+
234+
test('lyrics wrapper should use shrink class when measured', async ({ page }) => {
235+
await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK);
236+
await navigateToNowPlaying(page);
237+
await setLyrics(page, TRAIL_OF_DEAD_LYRICS);
238+
await waitForMeasurement(page);
239+
240+
const lyricsWrapper = page.locator(
241+
'[data-testid="lyrics-layout"] > div > div:nth-child(2)',
242+
);
243+
const classes = await lyricsWrapper.getAttribute('class');
244+
expect(classes).toContain('shrink');
245+
expect(classes).not.toContain('flex-1');
246+
});
247+
});

0 commit comments

Comments
 (0)