import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { PlayerController } from '@store/controllers/player-controller'; import { designTokens } from '../../../styles/tokens.css'; import { PHONE_QUERY } from '../../../utils/breakpoints'; /** * How far through the song we are, on the border between the mini * player and the tab bar (#58). * * The phone's bottom bar carries three controls and no seek bar — plan * 016 B2 took it out, because 4px of height is not a thumb target and the * full-screen `now-playing-view` is where seeking belongs. What went * with it is the one thing a mini player is expected to say without * being opened: how far through the song it is. This is that, and * only that. * * Four things about it are load-bearing. * * **It is the shell's element, not either bar's.** The mini player and * `` are separate components stacked in the shell's grid, * so a line on the border between them is a row of the grid — either * one drawing it means reaching into the other's box for two pixels. * * **It never counts.** The position is pushed at 1 Hz by the backend * (`PlaybackPositionChanged`), and the interval here interpolates * *between* those reports and is stopped and restarted by every one of * them — the seek bar's rule, for the reason the seek bar has it: a * local clock drifted 30 s away from the backend across four keyboard * seeks. The `trackChangeId` and `seq` guards come along for the same * reason: the store is a singleton, so a report about the previous * track must not be adopted, and the same second reported twice still * has to reset the interpolation. * * **It is not a control and cannot become one.** `aria-hidden` on the * host and `pointer-events: none` throughout: the real progress is * announced by the seek bar on Now Playing, and a 2px strip on the top * edge of the tab bar that sometimes seeks is worse than one that * never does. It is also where a thumb aiming at a tab lands. * * **It renders nothing above 600px**, from `matchMedia` rather than a * media query, because that decides whether the element *exists* — and * with it whether a 1 Hz interval runs for the life of every desktop * session about a line nobody can see. `job-band`, `search-trigger` * and `player-controls` are the same pattern for the same reason. */ /** * The reporting cadence, matched. This is not the clock: it exists * only so the line moves in the second between two reports, and its * error is discarded by the next one rather than carried. */ const InterpolationIntervalMillis = 1000; @customElement('player-progress-line') export class PlayerProgressLine extends LitElement { private player = new PlayerController(this); /** Phone width. See the class comment: existence, not paint. */ @state() private phone = false; /** Seconds into the track, from the last report plus interpolation. */ @state() private elapsed = 0; private previousTrackChangeId = -1; /** The sequence number of the last backend report applied. */ private previousPositionSeq = -1; private timerID = -1; private media?: MediaQueryList; private onMedia = (e: MediaQueryListEvent) => { this.phone = e.matches; }; static override styles = [ designTokens, css` :host { display: block; /* Not a target, at any depth. */ pointer-events: none; } .track { height: 2px; background-color: var(--yj-bg-surface, #212529); } .fill { height: 100%; background-color: var(--yj-accent, #ffd43b); /* scaleX off a full-width box rather than a width in percent, so the moving thing is a transform and the line costs no layout once a second. */ transform-origin: left center; } `, ]; private get trackLength(): number { return this.player.currentTrack?.trackLength ?? 0; } override connectedCallback(): void { super.connectedCallback(); // Decorative in full: the seek bar on Now Playing is what // announces the position, and this says the same thing without // a name, a value or a way to act on it. this.setAttribute('aria-hidden', 'true'); this.media = window.matchMedia(PHONE_QUERY); this.phone = this.media.matches; this.media.addEventListener('change', this.onMedia); } override disconnectedCallback(): void { super.disconnectedCallback(); this.stopInterpolating(); this.media?.removeEventListener('change', this.onMedia); } override updated(): void { // A track change resets the line, and `trackChangeId` is what // reveals one when the same file plays twice in a row. const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1; if (currentChangeId !== this.previousTrackChangeId) { this.previousTrackChangeId = currentChangeId; this.elapsed = this.player.currentTrack?.seekPosition ?? 0; this.stopInterpolating(); } // The backend's own position wins over anything counted here, // and a report for a track that is no longer loaded is stale by // definition. const position = this.player.position; if ( position && position.trackChangeId === currentChangeId && position.seq !== this.previousPositionSeq ) { this.previousPositionSeq = position.seq; this.elapsed = position.positionSeconds; this.stopInterpolating(); } // One owner for the interval, as in `seek-bar`: everything that // wants it started or stopped says so by changing state that // brings us back here. if (this.phone && this.player.isPlaying && currentChangeId !== -1) { this.startInterpolating(); } else { this.stopInterpolating(); } } private stopInterpolating(): void { if (this.timerID !== -1) { clearInterval(this.timerID); this.timerID = -1; } } private startInterpolating(): void { if (this.timerID !== -1) { return; } this.timerID = window.setInterval(() => { if (this.elapsed < this.trackLength) { this.elapsed += 1; } }, InterpolationIntervalMillis); } override render() { // Nothing playing is nothing to say, and the grid row is `auto` // so an empty render costs no height at all -- `job-band`'s // rule one row down. if (!this.phone || this.player.currentTrack === null) return nothing; const length = this.trackLength; const fraction = length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0; return html`
`; } } declare global { interface HTMLElementTagNameMap { 'player-progress-line': PlayerProgressLine; } }