Two things, and the first was a defect underneath the design question rather than an answer to it. **The album art was never square.** aspect-ratio is specified not to re-derive the width when max-height clamps the height, unlike an intrinsic ratio, which is preserved under both bounds. So a definite `width: min(100%, 60vh)` kept its width while the height was clipped and object-fit: cover cropped a square cover into the band -- 264x53 on the reference device, which is what #172's "39px of art" actually looked like. It is not only the phone either: the leftover exceeds the width only above ~843px of viewport, so every height from ~500 to ~843 drew a crop. Both maxes with auto sizes is the fix, chosen by measuring four candidate rules against Chrome 113 itself at five column heights. The placeholder cannot use that rule -- with no intrinsic size it collapses to its icon, 13x58 -- so it is driven from the height, with min-width: 0 because a flex item's automatic minimum is its content, and max-height: calc(100vw - 2rem) because a non-replaced box cannot express "the largest square that fits" and went 380x484 on a tall phone without it. **Then the reflow.** The stacked budget is fixed, so the art gets `height - 386` and that is 53px at 424x439. #172 named two ways out; a floor on the art scrolls the transport off the bottom, and controls never scrolling off is #51's own Direction and plan 018's promise -- so below 500px the art and the names share a row, where the art is bounded by the row's height rather than the column's leftover. 53px to 143px on the device, nothing scrolling, the transport untouched. 500 is where the two layouts cross rather than a round number, and it is keyed on height alone because it answers vertical room: a 900x450 window has the same problem and the same fix.
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
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 {
|
|
creditLink,
|
|
albumLink,
|
|
exploreLinkStyles,
|
|
} from '@utils/explore-link';
|
|
import { PlayerController } from '@store/controllers/player-controller';
|
|
import { creditStore } from '@store/credit-store';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { srOnly } from '../../styles/sr-only.css';
|
|
import { ICON_QUEUE } from '@utils/icon-language';
|
|
import { openQueue as showQueue } from '@utils/open-queue';
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/** Unsubscribes the credit-arrival repaint. */
|
|
private creditsUnsub?: () => void;
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
// Credits arrive after the track does, so the name this view is
|
|
// already showing has to be re-rendered when they land.
|
|
this.creditsUnsub = creditStore.subscribe(() => this.requestUpdate());
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.creditsUnsub?.();
|
|
this.creditsUnsub = undefined;
|
|
}
|
|
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);
|
|
}
|
|
|
|
/* The art and the names are one block, so that a short
|
|
screen can lay them out side by side without either of them
|
|
knowing about the other's box. Vertically it is exactly what
|
|
the host used to do -- same gap, art flexible, names fixed --
|
|
so the tall layout is unchanged. */
|
|
.stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75em;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
.art {
|
|
flex: 1 1 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 0;
|
|
}
|
|
|
|
.art img {
|
|
/* Square, and never larger than the room left over --
|
|
where "square" is a property of what is painted and not
|
|
just of what was asked for.
|
|
|
|
The previous rule asked for a square and did not get
|
|
one. width: min(100%, 60vh) makes the width definite,
|
|
aspect-ratio: 1 derives the height from it, and
|
|
max-height: 100% then clamps that height **without
|
|
re-deriving the width** -- which is how the
|
|
aspect-ratio property is specified to behave, unlike
|
|
an intrinsic ratio. So whenever the room left over was
|
|
shorter than the box was wide, the art was drawn as a
|
|
letterbox strip and object-fit: cover cropped the
|
|
cover to it. Measured on the reference device at
|
|
424x439: **264x53**, a 5:1 band of a square image.
|
|
|
|
That is not only the phone. The leftover exceeds the
|
|
width only above ~843px of viewport, so every height
|
|
from ~500 to ~843 -- most phones, and any small window
|
|
-- drew a cropped strip too.
|
|
|
|
Both maxes with auto sizes is the fix, and it is the
|
|
replaced-element path rather than the aspect-ratio
|
|
one: the used size preserves the ratio under *both*
|
|
bounds (CSS2.1 10.4), so the art is square at every
|
|
height. Checked against Chrome 113 itself -- the
|
|
device's engine -- at column heights of 288, 300, 451,
|
|
600 and 800: square at all five, where the old rule
|
|
cropped at four.
|
|
|
|
A corollary worth knowing: auto will not upscale past
|
|
the image's natural size, and the largest tier
|
|
saveCoverArt keeps is 400px. Drawing it larger was
|
|
upscaling, so nothing is lost. */
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
width: auto;
|
|
height: auto;
|
|
aspect-ratio: 1;
|
|
object-fit: cover;
|
|
border-radius: 12px;
|
|
background-color: var(--yj-bg-elevated, #343a40);
|
|
}
|
|
|
|
/* The placeholder is not a replaced element, so it cannot use
|
|
the rule above: with no intrinsic size, auto/auto collapses
|
|
it to its icon -- measured at 13x58 in Chrome 113, which is
|
|
neither square nor the art's size.
|
|
|
|
So it is sized from the height, and then bounded by the
|
|
width in the one way a box like this can be. A non-replaced
|
|
element cannot express "the largest square that fits" in a
|
|
single rule: aspect-ratio derives the second axis from the
|
|
first, and whichever max clamps it does not re-derive the
|
|
other, which is the same trap the image rule above is about.
|
|
Driving it from the height alone is right until the column
|
|
is taller than it is wide -- ~843px of viewport, which is a
|
|
tall phone and #51's other named device -- and there it went
|
|
380x484.
|
|
|
|
max-height in viewport units is what closes it, and it is
|
|
sound here for the reason 60vh was not: this view is a
|
|
phone-width detail view, so its content box really is the
|
|
viewport less the host's own 1rem gutters. It is a *max*, so
|
|
the failure mode if that ever stopped being true is a square
|
|
bounded slightly early rather than a crop. rem and not em --
|
|
this box sets font-size: 3rem for the icon, so 2em here
|
|
would be 96px. */
|
|
.art .placeholder {
|
|
height: 100%;
|
|
width: auto;
|
|
max-width: 100%;
|
|
max-height: calc(100vw - 2rem);
|
|
/* A flex item's automatic minimum is its content, so
|
|
without this the icon's own width becomes a floor and
|
|
the box goes wider than it is tall the moment the row is
|
|
shorter than the icon -- which is exactly the state a
|
|
job band puts this screen in. */
|
|
min-width: 0;
|
|
aspect-ratio: 1;
|
|
border-radius: 12px;
|
|
background-color: var(--yj-bg-elevated, #343a40);
|
|
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;
|
|
}
|
|
|
|
/* Below 500px of viewport the art and the names sit side by
|
|
side, and that is the whole of this screen's answer to a
|
|
short phone (#51).
|
|
|
|
The stacked layout cannot be rescued by sizing alone. Its
|
|
budget is fixed -- 48px of header, 143px of transport since
|
|
#64, 78px of names, 68px of padding and gaps -- so the art
|
|
gets height - 386, which on the reference device's 424x439
|
|
is **53px**. #172 measured 39px before #64 and named the
|
|
two options: give the art a floor and let the block scroll,
|
|
or reflow. A floor scrolls the transport off the bottom,
|
|
and "controls never scroll off" is #51's own Direction and
|
|
plan 018's promise -- so it is the reflow.
|
|
|
|
Sideways the art is bounded by the row's height rather than
|
|
by the column's leftover, which is the whole gain: the same
|
|
439px screen goes from a 53px sliver to **143px**, measured
|
|
on the device, with nothing scrolling and the transport
|
|
untouched.
|
|
|
|
500 is where the two layouts cross rather than a round
|
|
number. In a row the art is height - 296 and the names get
|
|
what is left of 392px, so the names hold 176px at exactly
|
|
500 and less above it; stacked, the art is height - 386,
|
|
which passes 176px at 562. Below 500 the row is the bigger
|
|
art *and* the readable one -- above it the column is, which
|
|
is why a tall phone (a Pixel 7's ~869) keeps the layout it
|
|
has. Unverified on that device: none was attached.
|
|
|
|
It is keyed on height alone, not on the phone's width,
|
|
because it is an answer to vertical room -- a 900x450 window
|
|
has the same problem and the same fix. */
|
|
@media (max-height: 500px) {
|
|
.stack {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
|
|
/* A square of the row's height. The box has to carry the
|
|
ratio here rather than the image, because in a row the
|
|
art's width is what the ratio has to produce -- and the
|
|
image's own rule then fits it to a box that is already
|
|
square. */
|
|
.art {
|
|
flex: 0 1 auto;
|
|
height: 100%;
|
|
aspect-ratio: 1;
|
|
}
|
|
|
|
.meta {
|
|
flex: 1 1 auto;
|
|
}
|
|
}
|
|
`];
|
|
|
|
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 goes through the same helper
|
|
* `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 -- which is exactly what this button was: it set `open`
|
|
* directly, so on a phone it produced a queue with no history entry
|
|
* behind it and back moved the page underneath instead (#55).
|
|
*/
|
|
private openQueue() {
|
|
showQueue();
|
|
}
|
|
|
|
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="stack">
|
|
<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">
|
|
${creditLink(
|
|
creditStore.credits(track.recordingMbid),
|
|
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.iconFor(favorited)}></wa-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="transport">
|
|
<seek-bar></seek-bar>
|
|
<!-- context="full": this view *is* the player, so the
|
|
transport is the page rather than a strip of it --
|
|
primary controls large, shuffle and repeat beneath
|
|
at normal size (#56). It is a property rather than
|
|
a media query because the bottom bar wants a
|
|
different answer at this same viewport. -->
|
|
<player-controls context="full"></player-controls>
|
|
<!-- Rendered unconditionally and absent on its own
|
|
terms where the device owns the volume (#64): the
|
|
control asks the player, not this view and not the
|
|
viewport. A hidden host draws no gap, so that is
|
|
29px of a 439px screen back to the album art
|
|
(#172). -->
|
|
<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=${ICON_QUEUE}></wa-icon>
|
|
</button>
|
|
</header>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'now-playing-view': NowPlayingView;
|
|
}
|
|
}
|