Files
yellowjacket/frontend/src/components/audio-player/controls/player-controls.ts
T
logan 218e4f5e99 feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was
33x21px -- in the bottom bar and on the full-screen view alike. #56
reports them as "the most important thing in the mobile app and they
are tiny", and that is the number behind it.

The context is a **property, not a media query**, and that is the whole
design. Everywhere else in this app a component states what it drops at
phone width itself, because a media query inside a shadow root is
answered by the viewport and that is the honest signal. Here the two
hosts want different answers at the *same* viewport: on a phone the bar
wants three controls sized for a thumb and now-playing-view wants five,
larger still. So the host says which context and the viewport says
which size band, and neither alone can express it.

Play/pause alone goes above the 44px floor. A row of five identical
squares says every action is equally likely, which is not true of play
-- "large play/pause, adequate prev/next" is the Direction, and a spec
caught that the first version had sized all three the same.

Two things that fail silently:

The desktop bar must not move, and a `<button>` does not inherit its
font from its parent -- the UA stylesheet gives it one. So a generic
`font-size: inherit` is not the no-op it reads as: it took every
desktop control from 33x21 to 36x24. The box rules take a zero fallback
and the font-size rules are scoped to the two contexts that set one.

And the art on now-playing-view overflowed its own box, drawing over
the header above and the title below, because `aspect-ratio: 1` with a
definite width derives a height that nothing bounds -- 60vh bounds the
viewport, not the room left over. `max-height: 100%`. Pre-existing;
found by reading a screenshot, which is the only tier that can see it.

What is left is #172: with the transport at 172px of a 439px screen the
art is a 39px sliver.

Closes #56
2026-08-20 23:42:15 -04:00

361 lines
11 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* The transport, in the two places it appears.
*
* **The context is a property and cannot be a media query**, which is
* the whole reason this exists (#56). Everywhere else in this app a
* component states what it drops at phone width itself, because a media
* query inside a shadow root is answered by the viewport and that is
* the honest signal. Here the two hosts want *different* answers at the
* *same* viewport: on a phone the bottom bar wants three controls sized
* for a thumb, and `now-playing-view` wants five, larger still. So the
* host says which context and the viewport says which size band, and
* neither one alone can express it.
*
* Measured at the reference device's 424x439 before this: every button
* here was **33x21px**, in both places, which is what #56 reports as
* "the most important thing in the mobile app and they are tiny".
*/
export type ControlsContext = 'bar' | 'full';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private unsubscribeQueue?: () => void;
/**
* Where these controls are drawn. `bar` is the bottom bar in both
* bands; `full` is the full-screen transport.
*
* Reflected so a spec can read it and so the stylesheet keys off one
* fact rather than a class the host has to remember to set.
*/
@property({ type: String, reflect: true })
context: ControlsContext = 'bar';
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
/**
* Phone width, from `matchMedia` rather than from a media query,
* because what it decides is whether shuffle and repeat *exist* here
* — and a stylesheet can only decide whether they are painted.
* `job-band` and `search-trigger` are the same pattern for the same
* reason.
*/
@state() private phone = false;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
/** Whether this is the phone's bottom bar, which carries three
* controls rather than five. */
private get slim(): boolean {
return this.context === 'bar' && this.phone;
}
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
this.unsubscribeQueue = queueStore.subscribe(() => {
const qs = queueStore.getState();
if (
qs.shuffleMode !== this.shuffleMode ||
qs.repeatMode !== this.repeatMode
) {
this.shuffleMode = qs.shuffleMode;
this.repeatMode = qs.repeatMode;
}
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
this.media?.removeEventListener('change', this.onMedia);
}
static override styles = [designTokens, css`
#player-control-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
}
button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------
Sizes (#56).
44px is the floor everything here is sized to, and play/pause
alone goes above it -- "large play/pause, adequate prev/next" is
the Direction, and it is the one control the report calls "front
and centre".
They are stated as custom properties rather than on each button
so a context sets two numbers instead of five rules, and so the
icon scales with its target: a 44px box around a 16px glyph is a
big hit area that still looks tiny, which is half of what the
report is about.
**The desktop bar sets none of them and must not change at all.**
#56 is an Android issue; the desktop's buttons are 33x21 before
this and are 33x21 after it.
That is why the box rules take a zero fallback and the *font-size*
rules are scoped to the two contexts instead of sharing them. A
button does not inherit its font from its parent -- the UA
stylesheet gives it one -- so a generic font-size: inherit is
not the no-op it reads as: it moved the desktop's buttons from
33x21 to 36x24, silently, by taking them from the UA's 13.3px to
the shell's 16px. Measured before and after by stashing this
file, which is the only way that particular 3px shows up.
--------------------------------------------------------------- */
button {
min-width: var(--yj-control-target, 0);
min-height: var(--yj-control-target, 0);
}
button.play {
min-width: var(--yj-control-play-target, 0);
min-height: var(--yj-control-play-target, 0);
}
/* The phone's bottom bar: three controls, sized for a thumb.
Shuffle and repeat are not here -- see the render method, which
does not draw them rather than hiding them, because a control
that is display:none is still a thing the component claims to
have. They are on the full-screen view, which is one tap away
through the mini player's art (#59). */
@media (max-width: 599px) {
:host([context='bar']) {
--yj-control-target: 44px;
--yj-control-icon: 18px;
--yj-control-play-target: 56px;
--yj-control-play-icon: 24px;
}
:host([context='bar']) button {
font-size: var(--yj-control-icon);
}
:host([context='bar']) button.play {
font-size: var(--yj-control-play-icon);
}
}
/* The full-screen transport, at every width: this view *is* the
player, so the controls are the page rather than a strip of it. */
:host([context='full']) {
--yj-control-target: 44px;
--yj-control-icon: 20px;
--yj-control-play-target: 64px;
--yj-control-play-icon: 28px;
}
:host([context='full']) button {
font-size: var(--yj-control-icon);
}
:host([context='full']) button.play {
font-size: var(--yj-control-play-icon);
}
:host([context='full']) #player-control-buttons {
gap: 12px;
}
/* Secondary controls sit below the primary row rather than beside
it, which is the Direction's shape and is why this is a second
group in the DOM instead of a CSS order property: visual order
and focus order have to agree. */
.secondary {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 8px;
}
button:hover {
color: var(--yj-accent-text, #ffd43b);
}
.active {
color: var(--yj-accent-text, #ffd43b);
}
.repeat-one {
position: relative;
}
.repeat-one::after {
content: '1';
font-size: 8px;
font-weight: bold;
position: absolute;
bottom: 2px;
right: 2px;
}
`];
private handlePlayClick = () => {
queueStore.play();
};
private handlePauseClick = () => {
this.player.pause();
};
private handleNextClick = () => {
queueStore.next();
};
private handlePreviousClick = () => {
queueStore.previous();
};
private handleShuffleClick = () => {
queueStore.toggleShuffle();
};
private handleRepeatClick = () => {
queueStore.cycleRepeat();
};
/** Shuffle. Secondary: it changes how the queue behaves rather than
* what is playing now. */
private renderShuffle() {
return html`
<button
class=${this.shuffleMode ? 'active' : ''}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
`;
}
/** Repeat, whose label spells the mode out because one icon covers
* three states. */
private renderRepeat() {
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' ');
return html`
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
`;
}
/** Previous, play/pause, next — the three that are always drawn, in
* every context and at every width. Only play/pause takes the large
* size: the Direction asks for "large play/pause, adequate
* prev/next", and a row of identical squares says every action here
* is equally likely, which is not true of play. */
private renderPrimary() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
return html`
<button
aria-label="Previous track"
@click=${this.handlePreviousClick}
>
<wa-icon name="backward-step"></wa-icon>
</button>
<button
class="play"
aria-label=${this.player.isPlaying ? 'Pause' : 'Play'}
@click="${playOrPauseHandler}"
>
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button
aria-label="Next track"
@click=${this.handleNextClick}
>
<wa-icon name="forward-step"></wa-icon>
</button>
`;
}
/**
* Two arrangements, not two components.
*
* `bar` keeps the order it has always had — shuffle, prev, play,
* next, repeat, one row — so nothing about the desktop bar moves.
* `full` puts the primary three on their own row with the secondary
* pair beneath, which the Direction asks for.
*
* **The phone's bar draws three buttons rather than hiding two.** A
* `display: none` control is still in the component's shadow root,
* still in the accessibility tree's markup, and still something a
* `shadowAll('button')[4]` finds — so "the phone has three controls"
* would be true of the pixels and false of the element. They are
* reachable on the full-screen view, which the mini player's art
* opens, and through the global shortcuts.
*/
override render() {
if (this.context === 'full') {
return html`
<div id="player-control-buttons">${this.renderPrimary()}</div>
<div class="secondary">
${this.renderShuffle()}${this.renderRepeat()}
</div>
`;
}
return html`
<div id="player-control-buttons">
${this.slim ? nothing : this.renderShuffle()}
${this.renderPrimary()}
${this.slim ? nothing : this.renderRepeat()}
</div>
`;
}
}