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'); + }); +});