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:
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user