feat(ui): the full-screen now playing a phone needs
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
CI / e2e (push) Successful in 5m55s

Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
This commit is contained in:
2026-08-17 00:22:58 -04:00
parent 29299d17da
commit 1b05dde382
10 changed files with 767 additions and 93 deletions
+54
View File
@@ -2929,3 +2929,57 @@ page rather than a failed setup.
both times.** That is the property to keep — a spec tier whose second
run differs from its first is a tier that will one day blame the wrong
commit.
## A media query adds no specificity, and dead CSS looks like working CSS (2026-08-16)
Plan 016 B2 phase 2 shipped the full-screen now-playing view, and
checking it with a screenshot found that **phase 1's shell rules had
never applied**.
`index.css` is base rules then component rules, and the phone block had
been inserted in the middle — above the plain `.top-bar` and `.title`
rules it meant to override. A media query is not a specificity boost,
so with equal specificity the *later* declaration wins. Measured at
390px before the fix:
| declared for the phone | actually computed |
|---|---|
| `padding-left: 0.75em` | 32px (the 2em base) |
| `gap: 0.5em` | 16px (base) |
| `font-size: 1.1em` | 24px (the 1.5em base) |
| `grid-template-columns: minmax(0,1fr) auto auto` | `320px 1fr auto` (base) |
After moving the block to the end of the file: 12px, 8px, 17.6px, and
`154px 187px 33px`.
**Nothing failed while they were dead**, which is the part worth
keeping. The phone spec asserts that the shell does not scroll
sideways, and it did not — because the fitting was being done by
`min-width: 0` and by each component's *own* media query, which live in
their own stylesheets and so had no later rule to lose to. The
declarations that did nothing were the cosmetic ones, and no assertion
was ever going to see them. A screenshot did, in about ten seconds.
The file now ends with one phone section, and says why it is last.
### What the same screenshot found about the view itself
The bottom bar was still rendering the mini player *underneath* the
full-screen view — 4em of a 844px phone spent saying exactly what the
view above it says, and invisible to every assertion about either one
(both were correct on their own). `index.css` hides `.bottom-bar` while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from `index.ts`: which view is showing is
already published as an attribute, and a second expression of the same
fact is a second thing to keep in step.
That took the queue button away with it, since that button lives in the
bar — so the view carries its own, toggling the same `open` attribute
on the same panel element.
**And a css`` literal cannot contain a backtick.** A comment reading
"the track size is set on the `wa-slider` inside its shadow root"
terminates the tagged template, and the failure arrives as
`Expected "]" but found "wa"` from the CSS parser, at a line number in
the *comment*. `make css-check` exists for this and named it
immediately.
@@ -315,14 +315,22 @@ places had to agree — `abiFilters`, the Makefile's `android:package`
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below) and **phase 1 is
done**: the shell itself. Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before. What is left
is the *views*: a full-screen now-playing (which is where seeking and
volume went), long-press for the context menus that are right-click
today, and the track list's resizable columns, which are a pointer
feature with no touch equivalent.
**B2, the desktop shell.** Scope decided (below); **phases 1 and 2 are
done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before.
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
and volume went. A detail view, so Back pops the nav stack; it
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
What is left is the rest of the *interactions*: long-press for the
context menus that are right-click today, and the track list's
resizable columns, which are a pointer feature with no touch
equivalent. Neither is started.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
+22
View File
@@ -1045,6 +1045,28 @@ is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**The phone section of `index.css` is last on purpose.** A media query
adds no specificity, so a `@media (max-width: 599px)` block placed
above the plain rules it overrides loses to them — which is how phase 1
shipped a header that kept its 2em gutters and 24px title on a 390px
phone with every declaration dead and nothing failing. The shell fitted
anyway, because the fitting is done by `min-width: 0` and by each
component's own media query, which live in their own stylesheets and
have no later rule to lose to. Cosmetic declarations are exactly what
no assertion sees; a screenshot found it.
**`<now-playing-view>` is where the seek bar and volume went.** It is a
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
a tab you cannot leave by pressing again is not a tab), reached from a
phone-only button over the mini player's art, and it **composes the
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
than reimplementing them. While it is up, `index.css` hides the bottom
bar through `body:has(#main-content[data-active-view="now-playing"])`
the active view is already published as an attribute, and a class
toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
+35
View File
@@ -112,6 +112,41 @@ test.describe('the shell on a phone', () => {
});
}
test('opens the full-screen now playing, and comes back', async ({ app }) => {
// Something has to be playing for the mini player to be a way in.
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
await app.locator('track-list .track-row').first().dblclick();
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
// The seek bar and volume that phase 1 took out of the bottom bar
// are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
await app.getByTestId('npv-back').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
// The button exists in the markup at every size; CSS decides. If
// this becomes visible on a desktop it is a 48px hit target over
// the cover art, swallowing the clicks that open the preview.
await expect(app.getByTestId('open-now-playing')).toBeHidden();
});
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
// The player bar stays: this is a music player, and what is playing
// has to be visible and pausable from every view.
+107 -85
View File
@@ -41,79 +41,7 @@ body {
overflow-y: hidden;
}
/* ---------------------------------------------------------------
The phone shell (plan 016 B2).
600px, not the sidebar's 900: 900 is a *laptop* and the response to
it is a narrower sidebar, which is still a sidebar. Below 600 there
is no room for one at all -- 360px of viewport over a 200px nav is
not a layout -- so the navigation moves to the bottom, where a thumb
is, and the eleven-item list moves into `bottom-nav`'s drawer.
The grid loses its sidebar column rather than hiding the element in
place: a named area with nothing in it still reserves its track.
--------------------------------------------------------------- */
@media (max-width: 599px) {
body {
grid-template:
"top-bar" 3.25em
"main-panel" 1fr
"bottom-bar" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
allowed to overflow a zoomed-in window (a11y.21 above); a
phone *is* the small viewport, so the shell has to fit it. */
overflow-x: hidden;
}
body div.sidebar {
display: none;
}
bottom-nav {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
overflow: hidden;
}
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
/* Above the phone breakpoint the tab bar does not exist. It is in the
markup unconditionally and eagerly, for the reason notification-host
@@ -214,20 +142,7 @@ body div.sidebar {
contain: layout style paint;
}
/* The transport at phone width: art, title and the controls, with the
seek bar and volume dropped by the components that own them. The
desktop's three fixed columns start with a 320px now-playing, which
at 360px of viewport leaves the controls 40px. */
@media (max-width: 599px) {
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
}
.bottom-bar audio-player {
margin: 0.25em;
}
}
.bottom-bar {
grid-area: bottom-bar;
@@ -340,3 +255,110 @@ body div.sidebar {
pointer-events: none !important;
contain: strict !important;
}
/* ===================================================================
The phone shell (plan 016 B2).
**This section is last on purpose.** A media query adds no
specificity, so `@media (max-width: 599px) { .title { … } }` placed
above the plain `.title` rule loses to it -- which is exactly what
happened when this landed in the middle of the file: the header kept
its 2em gutters, its 16px gap and its 24px title on a 390px phone,
and every one of these declarations was dead. Nothing failed,
because the shell fits for a different reason (the `min-width: 0`
below and each component's own media query), so a screenshot was
what caught it.
600px, not the sidebar's 900: 900 is a *laptop* and the response to
it is a narrower sidebar, which is still a sidebar. Below 600 there
is no room for one at all -- 360px of viewport over a 200px nav is
not a layout -- so the navigation moves to the bottom, where a thumb
is, and the eleven-item list moves into `bottom-nav`'s drawer.
=================================================================== */
@media (max-width: 599px) {
body {
grid-template:
"top-bar" 3.25em
"main-panel" 1fr
"bottom-bar" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
allowed to overflow a zoomed-in window (a11y.21 above); a
phone *is* the small viewport, so the shell has to fit it. */
overflow-x: hidden;
}
body div.sidebar {
display: none;
}
bottom-nav {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
overflow: hidden;
}
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
every assertion about either one.
`:has()` rather than a class toggled from index.ts: which view
is showing is already published as an attribute, and a second
expression of the same fact is a second thing to keep in step.
The view carries its own queue button, because this is where
that one lived. */
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
}
.bottom-bar audio-player {
margin: 0.25em;
}
}
+12
View File
@@ -127,6 +127,11 @@ const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
import('@components/explore-artist-details/explore-artist-details.js'),
'explore-album-details': () =>
import('@components/explore-album-details/explore-album-details.js'),
// A detail view rather than a primary one on purpose: it is
// somewhere you go and come back from, so the nav stack carries
// the way out (016 B2 phase 2).
'now-playing': () =>
import('@components/now-playing-view/now-playing-view.ts'),
};
// Opened from a menu rather than by navigating, so they have no entry
@@ -305,6 +310,13 @@ async function handleNavigate(
currentDetailEl = spEl;
break;
}
case 'now-playing': {
const npEl = document.createElement('now-playing-view');
mainContent.appendChild(npEl);
currentDetailEl = npEl;
break;
}
case 'genre-details': {
const { genreName } = detail;
const genreEl = document.createElement('genre-details');
@@ -27,6 +27,18 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -0,0 +1,332 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../audio-player/controls/player-controls';
import '../audio-player/seekbar/seek-bar';
import '../audio-player/volume-control/volume-control';
import {
artistLink,
albumLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
* phase 2).
*
* Phase 1 took the seek bar and the volume out of the bottom bar,
* because 4px of height is not a thumb target and a phone's volume
* belongs to its hardware keys. This is where they went: the same
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
* desktop transport uses, given room. **Not copies of them** — a phone
* layout that reimplements the transport is a second transport to fix
* every bug in, and the seek bar in particular carries the
* interpolation rules that took a plan of their own to get right.
*
* It is a *detail* view rather than a primary one: it is somewhere you
* go and come back from, so `index.ts` pushes the current view onto the
* nav stack and Back pops it. That is also why it is not in the tab
* bar — a tab you cannot leave by pressing the same tab again is not a
* tab.
*/
@customElement('now-playing-view')
export class NowPlayingView extends LitElement {
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0.75em 1em 1.25em;
gap: 0.75em;
background-color: var(--yj-bg-surface, #212529);
overflow-y: auto;
}
header {
display: flex;
align-items: center;
gap: 0.5em;
flex: 0 0 auto;
}
.context {
flex: 1 1 auto;
}
.back {
background: none;
border: none;
color: var(--yj-text-primary, #f8f9fa);
/* 48px is the touch-target floor, and this is the control
that gets a user out of a full-screen view. */
min-width: 48px;
min-height: 48px;
font-size: 1.1rem;
cursor: pointer;
border-radius: 6px;
}
.back:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.context {
font-size: var(--yj-font-size-xs, 0.75rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yj-text-secondary, #adb5bd);
}
.art {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
.art .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--yj-text-tertiary, #868e96);
}
.meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.75em;
min-width: 0;
}
.names {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0;
/* Two lines, then an ellipsis. A marquee is the bottom
bar's answer to a 320px box; here there is room to wrap,
and wrapping does not move. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist,
.album {
margin: 0;
font-size: 0.9rem;
color: var(--yj-text-secondary, #adb5bd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite {
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
min-width: 48px;
min-height: 48px;
font-size: 1.25rem;
cursor: pointer;
border-radius: 6px;
}
.favorite.on {
color: var(--yj-accent, #ffd43b);
}
.favorite:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.transport {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The seek bar is the reason this view exists. Its own
stylesheet thickens the track below the phone breakpoint --
the track size is set on the wa-slider inside its shadow
root, so a custom property set from here would not reach
it. */
seek-bar {
display: block;
}
.empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
`];
private back() {
this.dispatchEvent(new CustomEvent('navigate-back', {
bubbles: true,
composed: true,
}));
}
/**
* Open the queue.
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
}
private toggleFavorite() {
const path = this.player.currentTrack?.filePath;
if (path) void this.favCtrl.toggleFavorite(path);
}
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
${this.renderHeader()}
<p class="empty" data-testid="npv-empty">
Nothing is playing.
</p>
`;
}
const favorited = this.favCtrl.isFavorited(track.filePath);
// The largest kept tier, which is what `saveCoverArt` records as
// the path -- there is no full-resolution original to reach for.
const art = track.coverArtLarge || track.coverArt;
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${artistLink(track.artist, track.artistMbid)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
</button>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<volume-control></volume-control>
</div>
`;
}
private renderHeader() {
return html`
<header>
<button
type="button"
class="back"
data-testid="npv-back"
aria-label="Back"
@click=${this.back}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<span class="context">Now playing</span>
<button
type="button"
class="back"
data-testid="npv-queue"
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name="list"></wa-icon>
</button>
</header>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'now-playing-view': NowPlayingView;
}
}
@@ -146,6 +146,41 @@ export class NowPlaying extends LitElement {
position: relative;
}
/* The phone's way into the full-screen now-playing view (016 B2
phase 2). It sits over the cover art rather than being a
thirteenth control in a 360px bar, and it is a *button* rather
than a click handler on the art because it is an action with a
name -- the art itself is decorative and the title beside it
already navigates somewhere else (the catalog page).
CSS owns whether it exists, the same way it does for bottom-nav:
there is no viewport check in the component. */
.expand {
display: none;
}
@media (max-width: 599px) {
.expand {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
/* The art shows through; this is a target, not a picture. */
color: transparent;
}
.expand:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
}
.cover-preview-panel {
width: 500px;
height: 500px;
@@ -377,6 +412,13 @@ export class NowPlaying extends LitElement {
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div
class="cover-art"
@mouseenter=${this.handleCoverMouseEnter}
@@ -485,6 +527,15 @@ export class NowPlaying extends LitElement {
`;
}
/** Open the full-screen view. Phone only; see `.expand`. */
private openNowPlaying = () => {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
composed: true,
}));
};
// ===================================================================
// SCROLL LOGIC
// ===================================================================
@@ -0,0 +1,126 @@
/**
* The full-screen now-playing view (plan 016 B2, phase 2).
*
* What is worth pinning here is not the layout but the *composition*:
* it renders the same `<seek-bar>`, `<player-controls>` and
* `<volume-control>` the desktop transport does, rather than its own.
* A phone layout that reimplements the transport is a second transport
* to fix every bug in — and the seek bar in particular carries
* interpolation rules that took a plan of their own to get right.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/now-playing-view/now-playing-view';
import { Events } from '../../src/events';
import { emit, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
const TRACK: TrackInfo = {
fileName: 'tideline.mp3',
filePath: '/music/tideline.mp3',
trackLength: 245,
seekPosition: 0,
state: 'playing',
title: 'Tideline',
artist: 'Sea Change',
album: 'Ebb',
coverArt: '/covers/ebb.jpg',
coverArtSmall: '/covers/ebb_sm.jpg',
coverArtMedium: '/covers/ebb_md.jpg',
coverArtLarge: '/covers/ebb_lg.jpg',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('now-playing-view', () => {
beforeEach(() => {
resetHarness();
});
it('reuses the real transport components', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
}
});
it('shows the track, and the largest cover tier that is kept', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
// `saveCoverArt` records the largest *tier* as the path; there is
// no full-resolution original on disk to reach for.
expect(
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
).toBe('/covers/ebb_lg.jpg');
});
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
// The player store is a singleton and outlives a test, so "no
// track" has to be stated rather than assumed from a fresh mount.
emit(Events.TrackChanged, null);
const el = await fixture('now-playing-view');
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
// …and the way out is still there, which is the whole point of
// rendering the header in both branches.
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
});
it('leaves by the nav stack, not by guessing where it came from', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
let backs = 0;
document.addEventListener('navigate-back', () => {
backs += 1;
});
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
// `navigate-back` pops what index.ts pushed. Dispatching a
// `navigate` to a hardcoded view would strand anyone who arrived
// here from a detail page.
expect(backs).toBe(1);
});
it('gives the favourite button a target and a state', async () => {
stub('playlist.Service.ToggleFavorite', undefined);
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
expect(fav).not.toBeNull();
expect(fav?.getAttribute('aria-pressed')).toBe('false');
// A button that says only "heart" says nothing; the name carries
// the track and the playlist it goes to.
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
});
it('gives the way out a thumb-sized target', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
expect(back?.getAttribute('aria-label')).toBe('Back');
});
});