import { LitElement, html, css } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { ref, createRef } from 'lit/directives/ref.js'; import WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js'; import { formatSeconds } from '@utils/time'; import { PlayerController } from '@store/controllers/player-controller'; import { designTokens } from '../../../styles/tokens.css'; import { waSliderLabel } from '../../../styles/wa-slider-label.css'; const ProgressIntervalMillis = 1000; @customElement('seek-bar') export class SeekBar extends LitElement { private player = new PlayerController(this); private rangeRef = createRef(); private timerID: number = -1; private previousTrackChangeId: number = -1; /** The sequence number of the last backend report applied. */ private previousPositionSeq: number = -1; @state() private seekValue: number = 0; /** * Whether the user is dragging the thumb right now. * * It is `@state` rather than a plain field because `updated()` owns * the interval and only reactive state brings `updated()` round. A * bare `stopProgress()` in the input handler mutated nothing, so * nothing re-rendered, so the tail of `updated()` that restarts the * interval never ran — and the only things that could restart it * were a `change` event or the next backend report. Any `input` * without a committed `change` therefore froze the interpolation: * a drag cancelled outside the element, a pointer taken by a scroll, * or a touch on the track treated as a scrub, which on a phone are * ordinary gestures. While playing, the 1 Hz report papered over it * within a second; with reports not arriving it was permanent. */ @state() private dragging: boolean = false; /** Whether the right-hand clock shows time remaining or total. */ @state() 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; margin: 0 16px; --wa-tooltip-background-color: var(--yj-bg-elevated, #343a40); --wa-tooltip-content-color: var(--yj-text-primary, white); --wa-tooltip-border-color: var(--yj-bg-elevated, #343a40); --wa-tooltip-border-radius: 4px; --wa-tooltip-font-size: var(--yj-text-lg); } wa-slider::part(track) { background: var(--yj-text-primary, white); } wa-slider::part(indicator) { background: var(--yj-accent, yellow); } wa-slider::part(thumb) { background: var(--yj-bg-base, black); } #seek-bar-container { display: flex; justify-content: space-between; align-items: center; } /* The clocks must not resize as they count. Two things move them, and they need different answers. Digits in a proportional font are different widths, so 1:11 is narrower than 4:08 and the bar breathed once a second -- that is what tabular figures fix. The character *count* changes too, at the hundredth minute and whenever the right-hand clock is toggled to remaining and grows a minus sign, and a figure width cannot fix that -- so each clock also reserves the widest string this track can put in it. The budget is per track rather than a constant because reserving six characters on every track would push the slider in by a character at each end for nothing. */ #seek-bar-container small, .time-toggle { font-variant-numeric: tabular-nums; flex: 0 0 auto; min-width: calc(var(--yj-clock-chars, 5) * 1ch); } #seek-bar-container small { text-align: left; } .time-toggle { background: none; border: none; padding: 0; color: inherit; font: inherit; font-size: var(--wa-font-size-s, 0.875rem); cursor: pointer; /* One more for the minus sign the remaining form carries. */ min-width: calc((var(--yj-clock-chars, 5) + 1) * 1ch); text-align: right; } .time-toggle:hover, .time-toggle:focus-visible { text-decoration: underline; } `]; // =================================================================== // DERIVED STATE // =================================================================== private get hasTrack(): boolean { return this.player.currentTrack !== null; } private get trackLength(): number { return this.player.currentTrack?.trackLength ?? 0; } private get isPlaying(): boolean { return this.player.isPlaying; } // =================================================================== // LIFECYCLE // =================================================================== override disconnectedCallback() { super.disconnectedCallback(); this.stopProgress(); this.endDrag(); } override updated() { // Detect track change and reset seek position. // Uses trackChangeId instead of filePath so the seek bar resets // even when the same file plays consecutively in the queue. const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1; if (currentChangeId !== this.previousTrackChangeId) { this.previousTrackChangeId = currentChangeId; this.seekValue = this.player.currentTrack?.seekPosition ?? 0; this.stopProgress(); } // The backend's own position wins over anything counted here. // Every report resets the interpolation, so the bar can be at most // one tick wrong and can never accumulate — which is what made a // keyboard seek desync it by 30 s (H-3). // A report for a track that is no longer loaded is stale by // definition: the change id is the only thing that distinguishes // it, since the same file can play twice in a row. // // A report arriving mid-drag is deliberately *not* applied: the // thumb belongs to the finger on it, and adopting a report once a // second pulls it back out from under them. The seq is left // unrecorded too, so the first report after the drag still counts // as fresh. const position = this.player.position; const forThisTrack = position !== null && position.trackChangeId === currentChangeId; if ( position && forThisTrack && !this.dragging && position.seq !== this.previousPositionSeq ) { this.previousPositionSeq = position.seq; this.seekValue = position.positionSeconds; this.stopProgress(); } // One owner for the interval, and this is it. Every other place // that wants it started or stopped says so by changing state that // brings us back here, so the timer cannot be left running by a // path that forgot to stop it or stopped by a path that forgot to // start it again. if (this.isPlaying && this.hasTrack && !this.dragging) { this.startProgress(); } else { this.stopProgress(); } } // =================================================================== // PROGRESS INTERVAL // =================================================================== private stopProgress() { if (this.timerID !== -1) { clearInterval(this.timerID); this.timerID = -1; } } /** * Interpolate between backend reports. * * This is not the clock — it exists only so the display moves * smoothly in the second between two ticks. It is stopped and * restarted by every report, so its error is bounded by one second * and is discarded rather than carried. */ private startProgress() { // Don't start multiple intervals if (this.timerID !== -1) { return; } this.timerID = window.setInterval(() => { if (this.seekValue < this.trackLength) { this.seekValue += 1; } }, ProgressIntervalMillis); } // =================================================================== // EVENT HANDLERS // =================================================================== private handleChange(e: Event) { const newSeekVal = (e.target as WaSlider).value; this.endDrag(); this.setSeekValue(newSeekVal); this.player.seek(newSeekVal); } /** * The user is moving the thumb. * * This only records that fact; `updated()` decides what it means for * the interval. `seekValue` follows the slider so the clocks track * the thumb during the drag rather than jumping when it is released. */ private handleInput(e: Event) { this.setSeekValue((e.target as WaSlider).value); if (this.dragging) { return; } this.dragging = true; // A drag that never commits must not strand the flag, or this fix // turns a stall of up to one second into a permanent one -- which // is the failure it exists to remove. `change` is the ordinary // end; these are the ones that are not, and they are on the // document because the pointer is routinely released outside the // element it started in. A drag's listeners belong to the drag, // so they go on with it and come off with it. document.addEventListener('pointerup', this.endDrag); document.addEventListener('pointercancel', this.endDrag); document.addEventListener('touchend', this.endDrag); document.addEventListener('touchcancel', this.endDrag); } private endDrag = () => { document.removeEventListener('pointerup', this.endDrag); document.removeEventListener('pointercancel', this.endDrag); document.removeEventListener('touchend', this.endDrag); document.removeEventListener('touchcancel', this.endDrag); this.dragging = false; }; private setSeekValue(val: number) { if (val < 0) val = 0; if (val > this.trackLength) val = this.trackLength; this.seekValue = val; } // =================================================================== // RENDER // =================================================================== /** H-16: the right-hand clock never said which number it was. */ private toggleRemaining() { this.showRemaining = !this.showRemaining; } override render() { const elapsedTime = this.hasTrack ? formatSeconds(this.seekValue) : '--:--'; const rightLabel = this.showRemaining ? `-${formatSeconds(Math.max(0, this.trackLength - this.seekValue))}` : formatSeconds(this.trackLength); const rightTime = this.hasTrack ? rightLabel : '--:--'; // The widest string either clock can hold for *this* track. The // duration is the longest elapsed value there can be, so its length // is the budget; `--:--` is five, which is also the floor. const clockChars = Math.max( 5, this.hasTrack ? formatSeconds(this.trackLength).length : 0, ); return html`
${elapsedTime}
`; } }