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`.
This commit is contained in:
+107
-85
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user