diff --git a/frontend/src/components/audio-player/controls/player-controls.ts b/frontend/src/components/audio-player/controls/player-controls.ts index 6353c8b..6a8030a 100644 --- a/frontend/src/components/audio-player/controls/player-controls.ts +++ b/frontend/src/components/audio-player/controls/player-controls.ts @@ -1,22 +1,77 @@ -import { LitElement, html, css } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +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; @@ -37,6 +92,7 @@ export class PlayerControls extends LitElement { override disconnectedCallback(): void { super.disconnectedCallback(); this.unsubscribeQueue?.(); + this.media?.removeEventListener('change', this.onMedia); } static override styles = [designTokens, css` @@ -58,6 +114,99 @@ export class PlayerControls extends LitElement { 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); } @@ -104,46 +253,107 @@ export class PlayerControls extends LitElement { queueStore.cycleRepeat(); }; - override render() { - const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play'; - const playOrPauseHandler = this.player.isPlaying - ? this.handlePauseClick - : this.handlePlayClick; + /** Shuffle. Secondary: it changes how the queue behaves rather than + * what is playing now. */ + private renderShuffle() { + return html` + + `; + } - const shuffleClass = this.shuffleMode ? 'active' : ''; + /** 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` + + `; + } + + /** 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` + + + + `; + } + + /** + * 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` +
+