From 1b05dde3825e87dc15d84ea7fcf45ccf6730769a Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 17 Aug 2026 00:22:58 -0400 Subject: [PATCH] feat(ui): the full-screen now playing a phone needs 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`. --- .planning/NOTES.md | 54 +++ .../pending/016-android-feature-parity.md | 24 +- CLAUDE.md | 22 ++ e2e/specs/phone-shell.spec.ts | 35 ++ frontend/index.css | 192 +++++----- frontend/index.ts | 12 + .../audio-player/seekbar/seek-bar.ts | 12 + .../now-playing-view/now-playing-view.ts | 332 ++++++++++++++++++ .../src/components/now-playing/now-playing.ts | 51 +++ .../test/components/now-playing-view.test.ts | 126 +++++++ 10 files changed, 767 insertions(+), 93 deletions(-) create mode 100644 frontend/src/components/now-playing-view/now-playing-view.ts create mode 100644 frontend/test/components/now-playing-view.test.ts diff --git a/.planning/NOTES.md b/.planning/NOTES.md index e734a5d..a66f1d7 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -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. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md index 58208af..59f1733 100644 --- a/.planning/plans/pending/016-android-feature-parity.md +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -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, -`` 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, + `` 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. diff --git a/CLAUDE.md b/CLAUDE.md index 7e36d58..1d2455a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. + +**`` 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 ``, `` and ``** 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 diff --git a/e2e/specs/phone-shell.spec.ts b/e2e/specs/phone-shell.spec.ts index d921cce..e6aadf9 100644 --- a/e2e/specs/phone-shell.spec.ts +++ b/e2e/specs/phone-shell.spec.ts @@ -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. diff --git a/frontend/index.css b/frontend/index.css index 994e41d..5d5fe0a 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -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; + } +} diff --git a/frontend/index.ts b/frontend/index.ts index 1490b63..a64db71 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -127,6 +127,11 @@ const DETAIL_LOADERS: Record Promise> = { 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'); diff --git a/frontend/src/components/audio-player/seekbar/seek-bar.ts b/frontend/src/components/audio-player/seekbar/seek-bar.ts index 127a679..95f38aa 100644 --- a/frontend/src/components/audio-player/seekbar/seek-bar.ts +++ b/frontend/src/components/audio-player/seekbar/seek-bar.ts @@ -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; diff --git a/frontend/src/components/now-playing-view/now-playing-view.ts b/frontend/src/components/now-playing-view/now-playing-view.ts new file mode 100644 index 0000000..2ec9699 --- /dev/null +++ b/frontend/src/components/now-playing-view/now-playing-view.ts @@ -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 + * ``, `` and `` 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()} +

+ Nothing is playing. +

+ `; + } + + 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()} + +
+ ${art + ? html`` + : html``} +
+ +
+
+

+ ${track.title || track.fileName} +

+

+ ${artistLink(track.artist, track.artistMbid)} +

+ ${track.album + ? html`

+ ${albumLink( + track.album, + track.releaseGroupMbid, + undefined, + track.artist, + )} +

` + : nothing} +
+ + +
+ +
+ + + +
+ `; + } + + private renderHeader() { + return html` +
+ + Now playing + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'now-playing-view': NowPlayingView; + } +} diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 8722625..bb7639a 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -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 {
${announcement}
+
{ + this.dispatchEvent(new CustomEvent('navigate', { + detail: { view: 'now-playing' }, + bubbles: true, + composed: true, + })); + }; + // =================================================================== // SCROLL LOGIC // =================================================================== diff --git a/frontend/test/components/now-playing-view.test.ts b/frontend/test/components/now-playing-view.test.ts new file mode 100644 index 0000000..aacd4c7 --- /dev/null +++ b/frontend/test/components/now-playing-view.test.ts @@ -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 ``, `` and + * `` 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(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(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(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(el, '[data-testid="npv-back"]'); + + expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48); + expect(back?.getAttribute('aria-label')).toBe('Back'); + }); +});