CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a bar sized for a bar they are a few characters of text, which is not a touch target — and explore-link holds its navigation for one double-click interval and drops it if a second click arrives, a gesture that exists so double-clicking a row can play it and that means nothing on touch. Below the shell's phone breakpoint the three render as plain text. The words are unchanged: the source line still says where the queue came from, because dropping the link is the change and dropping the information would be a different and worse one. The cover art already carries the phone-only button that opens the full-screen Now Playing view, which is where the links live. This is in JS rather than in the stylesheet because what changes is the content, not its appearance — no CSS rule takes a click handler off an element. matchMedia is read in connectedCallback for the reason the reduce-motion query beside it already is, so a test can answer it first. Two smaller things. PHONE_QUERY moves out of track-list.ts into utils/breakpoints.ts: it was a private const when one component needed it, and a second reader is where a copy starts drifting from index.css. And `phone` joins geometryKey(), because crossing the breakpoint swaps a link for a bare string and the marquee travels a distance read from measuring it — the words being identical either side is not the same as the box measuring the same. Closes #61
861 lines
28 KiB
TypeScript
861 lines
28 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, state } from 'lit/decorators.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import {
|
|
creditLink,
|
|
trackLink,
|
|
exploreLinkStyles,
|
|
} from '@utils/explore-link';
|
|
import {
|
|
describeQueueSource,
|
|
isQueueSourceNavigable,
|
|
navigateToQueueSource,
|
|
} from '@utils/queue-source-link';
|
|
import { PHONE_QUERY } from '@utils/breakpoints';
|
|
import { PlayerController } from '@store/controllers/player-controller';
|
|
import { creditStore } from '@store/credit-store';
|
|
import { QueueController } from '@store/controllers/queue-controller';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { srOnly } from '../../styles/sr-only.css';
|
|
|
|
// H-17: at 200 px the artist truncated to "The Orchestra Of" while
|
|
// ~400 px of empty space sat between it and the transport controls.
|
|
// The panel is user-resizable, so this only moves where it starts and
|
|
// how far it can go.
|
|
const MIN_WIDTH = 120;
|
|
const MAX_WIDTH = 500;
|
|
const DEFAULT_WIDTH = 320;
|
|
|
|
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
|
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
|
|
|
/** Pixels per second the text scrolls at. */
|
|
const SCROLL_SPEED = 30;
|
|
const MIN_DURATION = 3;
|
|
const MAX_DURATION = 15;
|
|
|
|
type ScrollMode = 'hover' | 'always' | 'never';
|
|
|
|
@customElement('now-playing')
|
|
export class NowPlaying extends LitElement {
|
|
private player = new PlayerController(this);
|
|
private queue = new QueueController(this);
|
|
private favCtrl = new FavoritesController(this);
|
|
|
|
@state()
|
|
private isDragging = false;
|
|
|
|
@state()
|
|
private showCoverPreview = false;
|
|
|
|
@state()
|
|
private scrollMode: ScrollMode = 'hover';
|
|
|
|
@state()
|
|
private titleOverflows = false;
|
|
|
|
@state()
|
|
private artistOverflows = false;
|
|
|
|
@state()
|
|
private titleHovered = false;
|
|
|
|
@state()
|
|
private artistHovered = false;
|
|
|
|
/**
|
|
* `prefers-reduced-motion: reduce`, live (a11y.15).
|
|
*
|
|
* It is a `@state` and not a CSS query because suppressing the
|
|
* *transition* is not enough: the cycle is a transition out, a
|
|
* `transitionend`, and a transition back, so removing the animation
|
|
* leaves the text translated off its own box and `onScrollCycleEnd`
|
|
* never fires to bring it back. The scroll has to not be armed at
|
|
* all, which is a decision `shouldScroll()` already owns.
|
|
*/
|
|
@state()
|
|
private reduceMotion = false;
|
|
|
|
private reduceMotionQuery?: MediaQueryList;
|
|
|
|
/**
|
|
* Phone width, from the shell's own breakpoint.
|
|
*
|
|
* This is in JS rather than in the stylesheet because what changes
|
|
* is the *content*, not its appearance: the title, artist and
|
|
* source render as plain text instead of as links, and no CSS rule
|
|
* can take a click handler off an element.
|
|
*/
|
|
@state()
|
|
private phone = false;
|
|
|
|
private phoneQuery?: MediaQueryList;
|
|
|
|
/** Whether each field is actively mid-scroll (class toggle). */
|
|
@state()
|
|
private titleScrolling = false;
|
|
|
|
@state()
|
|
private artistScrolling = false;
|
|
|
|
private scrollTimers: Record<string, ReturnType<typeof setTimeout> | null> = {
|
|
title: null,
|
|
artist: null,
|
|
};
|
|
|
|
private resizeObserver?: ResizeObserver;
|
|
|
|
/** See `geometryKey()` — perf.m5. */
|
|
private lastGeometryKey = '';
|
|
private geometryDirty = true;
|
|
|
|
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.now-playing {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 8px;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.cover-art {
|
|
width: 48px;
|
|
height: 48px;
|
|
flex-shrink: 0;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.cover-art img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cover-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: var(--yj-bg-base, #000);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.cover-placeholder wa-icon {
|
|
color: var(--yj-text-primary, #fff);
|
|
font-size: var(--yj-icon-lg);
|
|
}
|
|
|
|
.cover-art-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
/* The phone's way into the full-screen now-playing view (016 B2
|
|
phase 2). It sits over the cover art rather than being a
|
|
thirteenth control in a 360px bar, and it is a *button* rather
|
|
than a click handler on the art because it is an action with a
|
|
name -- the art itself is decorative and the title beside it
|
|
already navigates somewhere else (the catalog page).
|
|
|
|
CSS owns whether it exists, the same way it does for bottom-nav:
|
|
there is no viewport check in the component. */
|
|
.expand {
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 599px) {
|
|
.expand {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
/* The art shows through; this is a target, not a picture. */
|
|
color: transparent;
|
|
}
|
|
|
|
.expand:focus-visible {
|
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
|
|
.cover-preview-panel {
|
|
width: 500px;
|
|
height: 500px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.cover-preview-panel img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.track-info-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.track-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.fav-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
cursor: pointer;
|
|
color: var(--yj-text-tertiary, #666);
|
|
font-size: var(--yj-icon-sm);
|
|
transition: color 0.1s ease;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.fav-btn:hover {
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.fav-btn.favorited {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.fav-btn.favorited:hover {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* --- Scrollable text containers --- */
|
|
|
|
.track-title,
|
|
.track-artist {
|
|
position: relative;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.track-title {
|
|
font-size: var(--yj-text-lg);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.track-artist {
|
|
font-size: var(--yj-text-sm);
|
|
color: var(--yj-text-tertiary, #666);
|
|
}
|
|
|
|
/* Static ellipsis when not scrolling.
|
|
|
|
It has to be on .scroll-content, not on the outer span: the child
|
|
is an inline-block, so it is the box that overflows, and
|
|
text-overflow on an ancestor does not ellipsise an overflowing
|
|
inline-block descendant — it clips it. The outer rule was there
|
|
from the start and never produced an ellipsis in any mode; the
|
|
default mode is hover, so what every user saw when not hovering
|
|
was a title cut mid-glyph. Only visible in a screenshot. */
|
|
.track-title:not(.will-scroll) .scroll-content,
|
|
.track-artist:not(.will-scroll) .scroll-content {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.track-source {
|
|
font-size: var(--yj-text-xs, 0.75rem);
|
|
color: var(--yj-text-tertiary, #666);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.track-source.navigable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.track-source.navigable:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.scroll-content {
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Scrolling text: use a single transition instead of an infinite
|
|
CSS animation. The infinite animation + mask-image was repainting
|
|
every frame in software rendering mode (no DMABuf). A transition
|
|
only repaints during the active scroll, and pauses are free. */
|
|
.will-scroll .scroll-content {
|
|
transition: transform var(--scroll-duration, 5s) linear;
|
|
padding-right: 2em;
|
|
}
|
|
|
|
.will-scroll.scrolling .scroll-content {
|
|
transform: translateX(var(--scroll-distance, -100%));
|
|
}
|
|
|
|
.resize-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
cursor: col-resize;
|
|
background-color: transparent;
|
|
transition: background-color 0.15s ease;
|
|
z-index: 10;
|
|
}
|
|
|
|
.resize-handle:hover,
|
|
.resize-handle.dragging {
|
|
background-color: var(--yj-text-tertiary, #6c757d);
|
|
}
|
|
`];
|
|
|
|
/** Unsubscribes the credit-arrival repaint. */
|
|
private creditsUnsub?: () => void;
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.loadScrollMode();
|
|
this.updateWidth(DEFAULT_WIDTH);
|
|
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
|
|
|
// Looked up here rather than at module load so a test can install
|
|
// its own matchMedia before the element is created.
|
|
this.reduceMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
|
|
this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange);
|
|
|
|
// Same reasoning as above: looked up here, not at module load,
|
|
// so a test can install its own matchMedia first.
|
|
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
|
|
this.phone = this.phoneQuery?.matches ?? false;
|
|
this.phoneQuery?.addEventListener('change', this.handlePhoneChange);
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.geometryDirty = true;
|
|
this.requestUpdate();
|
|
});
|
|
|
|
// A credit arriving changes the rendered text, and the marquee
|
|
// measures that text — so this is a geometry change, not just a
|
|
// repaint. Saying so is what stops the bar scrolling to the
|
|
// old width.
|
|
this.creditsUnsub = creditStore.subscribe(() => {
|
|
this.geometryDirty = true;
|
|
this.requestUpdate();
|
|
});
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.creditsUnsub?.();
|
|
this.creditsUnsub = undefined;
|
|
// A drag interrupted by the bar going away still has to clean up.
|
|
this.attachDragListeners(false);
|
|
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
|
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
|
|
this.phoneQuery?.removeEventListener('change', this.handlePhoneChange);
|
|
this.resizeObserver?.disconnect();
|
|
this.stopScrollCycle('title');
|
|
this.stopScrollCycle('artist');
|
|
}
|
|
|
|
protected override updated(): void {
|
|
// perf.m5: this used to measure and rewrite the text geometry on
|
|
// every pass, and the player store notifies while playing — so a
|
|
// component whose DOM had not changed did six querySelectors and
|
|
// a read/write interleave several times a second for no news.
|
|
// The geometry depends on exactly four things, and the observer
|
|
// covers the fifth (the panel being resized).
|
|
const key = this.geometryKey();
|
|
|
|
if (key !== this.lastGeometryKey) {
|
|
this.lastGeometryKey = key;
|
|
this.geometryDirty = true;
|
|
}
|
|
|
|
if (this.geometryDirty) {
|
|
this.geometryDirty = false;
|
|
this.measureText();
|
|
}
|
|
|
|
this.syncScrollCycles();
|
|
}
|
|
|
|
override render() {
|
|
const track = this.player.currentTrack;
|
|
// Auto-advance changes the track with no announcement of any
|
|
// kind (a11y.12). The region is in both branches because it has
|
|
// to already exist when the *first* track arrives.
|
|
const announcement = track
|
|
? `Now playing: ${track.title}${track.artist ? ` by ${track.artist}` : ''}`
|
|
: '';
|
|
|
|
if (!track) {
|
|
return html`
|
|
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
|
|
<div class="now-playing">
|
|
<div class="cover-art">
|
|
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
|
|
@mousedown=${this.handleMouseDown}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
const isFav = track.filePath
|
|
? this.favCtrl.isFavorited(track.filePath)
|
|
: false;
|
|
const favVariant = isFav ? 'solid' : 'regular';
|
|
|
|
const titleScrolling = this.shouldScroll('title');
|
|
const artistScrolling = this.shouldScroll('artist');
|
|
|
|
return html`
|
|
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
|
|
<div class="now-playing">
|
|
<div class="cover-art-wrapper">
|
|
<button
|
|
type="button"
|
|
class="expand"
|
|
data-testid="open-now-playing"
|
|
aria-label="Open now playing"
|
|
@click=${this.openNowPlaying}
|
|
></button>
|
|
<div
|
|
class="cover-art"
|
|
@mouseenter=${this.handleCoverMouseEnter}
|
|
@mouseleave=${this.handleCoverMouseLeave}
|
|
>
|
|
${track.coverArt
|
|
? html`<img
|
|
src="${track.coverArtSmall || track.coverArt}"
|
|
alt="Album cover"
|
|
decoding="async"
|
|
@error=${(e: Event) => {
|
|
const img = e.target as HTMLImageElement;
|
|
if (
|
|
track.coverArt &&
|
|
img.src !== track.coverArt
|
|
) {
|
|
img.src = track.coverArt;
|
|
}
|
|
}}
|
|
/>`
|
|
: html`<div class="cover-placeholder">
|
|
<wa-icon name="music"></wa-icon>
|
|
</div>`}
|
|
</div>
|
|
<wa-popup
|
|
id="cover-preview"
|
|
placement="top-start"
|
|
flip
|
|
shift
|
|
.active=${this.showCoverPreview}
|
|
>
|
|
${this.showCoverPreview && track.coverArt
|
|
? html`
|
|
<div class="cover-preview-panel">
|
|
<img
|
|
src="${track.coverArt}"
|
|
alt="Album cover full size"
|
|
decoding="async"
|
|
/>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
</div>
|
|
<div class="track-info-wrapper">
|
|
<div class="track-info">
|
|
<span
|
|
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
|
|
data-testid="now-playing-title"
|
|
title=${track.title}
|
|
@mouseenter=${this.handleTitleMouseEnter}
|
|
@mouseleave=${this.handleTitleMouseLeave}
|
|
@transitionend=${() => this.onScrollCycleEnd('title')}
|
|
>
|
|
<span class="scroll-content">${this.phone ? track.title : trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
|
|
</span>
|
|
<span
|
|
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
|
|
data-testid="now-playing-artist"
|
|
title=${track.artist || 'Unknown Artist'}
|
|
@mouseenter=${this.handleArtistMouseEnter}
|
|
@mouseleave=${this.handleArtistMouseLeave}
|
|
@transitionend=${() => this.onScrollCycleEnd('artist')}
|
|
>
|
|
<span class="scroll-content">${this.phone ? track.artist || 'Unknown Artist' : creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span>
|
|
</span>
|
|
${describeQueueSource(this.queue.source)
|
|
? html`
|
|
<span
|
|
class="track-source ${!this.phone && isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
|
|
data-testid="now-playing-source"
|
|
@click=${(e: MouseEvent) => {
|
|
if (this.phone) return;
|
|
if (!isQueueSourceNavigable(this.queue.source)) return;
|
|
navigateToQueueSource(
|
|
e.currentTarget as EventTarget,
|
|
this.queue.source,
|
|
);
|
|
}}
|
|
>${describeQueueSource(this.queue.source)}</span>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
${track.filePath
|
|
? html`
|
|
<button
|
|
class="fav-btn ${isFav ? 'favorited' : ''}"
|
|
title="${isFav ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}"
|
|
@click=${() =>
|
|
void this.favCtrl.toggleFavorite(
|
|
track.filePath,
|
|
)}
|
|
>
|
|
<wa-icon
|
|
name=${this.favCtrl.iconFor(isFav)}
|
|
variant=${favVariant}
|
|
></wa-icon>
|
|
</button>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
|
|
@mousedown=${this.handleMouseDown}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
/** Open the full-screen view. Phone only; see `.expand`. */
|
|
private openNowPlaying = () => {
|
|
this.dispatchEvent(new CustomEvent('navigate', {
|
|
detail: { view: 'now-playing' },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
};
|
|
|
|
// ===================================================================
|
|
// SCROLL LOGIC
|
|
// ===================================================================
|
|
|
|
private loadScrollMode(): void {
|
|
const stored = localStorage.getItem(SCROLL_STORAGE_KEY);
|
|
|
|
if (stored === 'hover' || stored === 'always' || stored === 'never') {
|
|
this.scrollMode = stored;
|
|
}
|
|
}
|
|
|
|
private handleScrollModeEvent = (): void => {
|
|
this.loadScrollMode();
|
|
};
|
|
|
|
private handleReduceMotionChange = (e: MediaQueryListEvent): void => {
|
|
this.reduceMotion = e.matches;
|
|
};
|
|
|
|
private handlePhoneChange = (e: MediaQueryListEvent): void => {
|
|
this.phone = e.matches;
|
|
};
|
|
|
|
private shouldScroll(field: 'title' | 'artist'): boolean {
|
|
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
|
|
|
|
// A stated OS-level preference outranks an app default the user
|
|
// may never have touched, so this comes before the mode — and it
|
|
// covers `hover` as well as `always`. Hover-scrolling is
|
|
// user-initiated and so arguably passes WCAG 2.2.2 on its own,
|
|
// but `reduce` is a request about motion, not about autoplay.
|
|
// The text falls back to the ellipsis every other mode uses.
|
|
if (this.reduceMotion) return false;
|
|
|
|
if (!overflows || this.scrollMode === 'never') return false;
|
|
if (this.scrollMode === 'always') return true;
|
|
|
|
// hover mode
|
|
return field === 'title' ? this.titleHovered : this.artistHovered;
|
|
}
|
|
|
|
/**
|
|
* Everything the measured geometry depends on, other than the panel
|
|
* width — which the ResizeObserver reports instead.
|
|
*
|
|
* The two scroll flags are in here because `.will-scroll` puts
|
|
* `padding-right: 2em` on `.scroll-content`, so toggling it changes
|
|
* the scroll distance the animation travels. A guard that only
|
|
* watched the text would leave the first hover scrolling 2em short.
|
|
*/
|
|
private geometryKey(): string {
|
|
const track = this.player.currentTrack;
|
|
|
|
return [
|
|
track?.title ?? '',
|
|
track?.artist ?? '',
|
|
this.shouldScroll('title') ? '1' : '0',
|
|
this.shouldScroll('artist') ? '1' : '0',
|
|
// Crossing the breakpoint swaps a link for a bare string,
|
|
// and a link is not guaranteed to measure the same as the
|
|
// text inside it. The marquee travels a distance read from
|
|
// that measurement, so this belongs in the key even though
|
|
// the words are identical either side.
|
|
this.phone ? '1' : '0',
|
|
].join('\u0000');
|
|
}
|
|
|
|
/**
|
|
* Measure both text containers and set the CSS custom properties the
|
|
* scroll animation reads: how far to travel and how long to take.
|
|
*
|
|
* Every read happens before every write. Interleaving them is what
|
|
* makes the browser flush layout again per write, and this is the
|
|
* one place in the component that touches layout at all.
|
|
*/
|
|
private measureText(): void {
|
|
const titleEl = this.shadowRoot?.querySelector<HTMLElement>('.track-title');
|
|
const artistEl = this.shadowRoot?.querySelector<HTMLElement>('.track-artist');
|
|
|
|
const measure = (el: HTMLElement | null | undefined) => {
|
|
if (!el) return { overflows: false, distance: 0 };
|
|
|
|
const content = el.querySelector<HTMLElement>('.scroll-content');
|
|
const width = el.clientWidth;
|
|
|
|
// Both numbers come from the *child*, which is the box that
|
|
// holds the text. Asking the outer span whether it overflows
|
|
// only works while the child is an overflowing inline-block:
|
|
// once the non-scrolling state gives the child its own
|
|
// `overflow: hidden` (for the ellipsis), the parent stops
|
|
// overflowing and nothing ever arms the scroll again.
|
|
// `scrollWidth` reports the content size either way.
|
|
const full = content?.scrollWidth ?? 0;
|
|
|
|
return {
|
|
overflows: full > width,
|
|
distance: content ? full - width : 0,
|
|
};
|
|
};
|
|
|
|
const title = measure(titleEl);
|
|
const artist = measure(artistEl);
|
|
|
|
for (const [el, m] of [[titleEl, title], [artistEl, artist]] as const) {
|
|
if (el && this.resizeObserver) {
|
|
// ResizeObserver deduplicates observed elements itself.
|
|
this.resizeObserver.observe(el);
|
|
}
|
|
|
|
if (!el || !m.overflows || m.distance <= 0) continue;
|
|
|
|
const duration = Math.min(
|
|
MAX_DURATION,
|
|
Math.max(MIN_DURATION, m.distance / SCROLL_SPEED),
|
|
);
|
|
el.style.setProperty('--scroll-distance', `-${m.distance}px`);
|
|
el.style.setProperty('--scroll-duration', `${duration.toFixed(1)}s`);
|
|
}
|
|
|
|
// Assigned last: these are @state, so they schedule the pass that
|
|
// re-measures with the scroll classes applied.
|
|
this.titleOverflows = title.overflows;
|
|
this.artistOverflows = artist.overflows;
|
|
}
|
|
|
|
// ===================================================================
|
|
// TRANSITION-BASED SCROLL CYCLE
|
|
// ===================================================================
|
|
|
|
/**
|
|
* Start a scroll cycle for a field. Adds the `scrolling` class which
|
|
* triggers a CSS transition. When the transition ends, we pause then
|
|
* snap back and repeat. This replaces the old infinite CSS animation
|
|
* which repainted every frame even during the pause phases.
|
|
*/
|
|
private startScrollCycle(field: 'title' | 'artist'): void {
|
|
if (!this.shouldScroll(field)) return;
|
|
|
|
// Small delay before starting the scroll
|
|
this.scrollTimers[field] = setTimeout(() => {
|
|
if (field === 'title') this.titleScrolling = true;
|
|
else this.artistScrolling = true;
|
|
}, 1500);
|
|
}
|
|
|
|
private stopScrollCycle(field: 'title' | 'artist'): void {
|
|
if (this.scrollTimers[field] !== null) {
|
|
clearTimeout(this.scrollTimers[field]!);
|
|
this.scrollTimers[field] = null;
|
|
}
|
|
|
|
if (field === 'title') this.titleScrolling = false;
|
|
else this.artistScrolling = false;
|
|
}
|
|
|
|
private onScrollCycleEnd(field: 'title' | 'artist'): void {
|
|
// Transition finished → snap back after a pause, then repeat
|
|
if (field === 'title') this.titleScrolling = false;
|
|
else this.artistScrolling = false;
|
|
|
|
// Restart after a pause (2s at the scrolled-to position)
|
|
this.scrollTimers[field] = setTimeout(() => {
|
|
this.startScrollCycle(field);
|
|
}, 2000);
|
|
}
|
|
|
|
/** Called from updated() when shouldScroll state changes. */
|
|
private syncScrollCycles(): void {
|
|
for (const field of ['title', 'artist'] as const) {
|
|
const should = this.shouldScroll(field);
|
|
const active = this.scrollTimers[field] !== null ||
|
|
(field === 'title' ? this.titleScrolling : this.artistScrolling);
|
|
|
|
if (should && !active) {
|
|
this.startScrollCycle(field);
|
|
} else if (!should && active) {
|
|
this.stopScrollCycle(field);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// HOVER HANDLERS
|
|
// ===================================================================
|
|
|
|
private handleTitleMouseEnter = (): void => {
|
|
this.titleHovered = true;
|
|
};
|
|
|
|
private handleTitleMouseLeave = (): void => {
|
|
this.titleHovered = false;
|
|
};
|
|
|
|
private handleArtistMouseEnter = (): void => {
|
|
this.artistHovered = true;
|
|
};
|
|
|
|
private handleArtistMouseLeave = (): void => {
|
|
this.artistHovered = false;
|
|
};
|
|
|
|
// ===================================================================
|
|
// COVER PREVIEW
|
|
// ===================================================================
|
|
|
|
private handleCoverMouseEnter = () => {
|
|
const track = this.player.currentTrack;
|
|
|
|
if (!track?.coverArt) return;
|
|
|
|
this.showCoverPreview = true;
|
|
|
|
this.updateComplete.then(() => {
|
|
const popup =
|
|
this.shadowRoot?.querySelector<WaPopup>(
|
|
'#cover-preview',
|
|
);
|
|
const anchor = this.shadowRoot?.querySelector(
|
|
'.cover-art',
|
|
);
|
|
|
|
if (popup && anchor) {
|
|
popup.anchor = anchor;
|
|
}
|
|
});
|
|
};
|
|
|
|
private handleCoverMouseLeave = () => {
|
|
this.showCoverPreview = false;
|
|
};
|
|
|
|
// ===================================================================
|
|
// RESIZE
|
|
// ===================================================================
|
|
|
|
/** perf.m4: the resize's document listeners exist while it is
|
|
* dragging and not before — they used to run on every pointer move
|
|
* anywhere in the app, for the life of the process, to guard and
|
|
* return. */
|
|
private attachDragListeners(on: boolean): void {
|
|
const fn = on ? 'addEventListener' : 'removeEventListener';
|
|
|
|
document[fn]('mousemove', this.handleMouseMove as EventListener);
|
|
document[fn]('mouseup', this.handleMouseUp as EventListener);
|
|
}
|
|
|
|
private handleMouseDown = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
this.isDragging = true;
|
|
this.attachDragListeners(true);
|
|
};
|
|
|
|
private handleMouseMove = (e: MouseEvent) => {
|
|
if (!this.isDragging) return;
|
|
|
|
const rect = this.getBoundingClientRect();
|
|
const newWidth = e.clientX - rect.left;
|
|
const clampedWidth = Math.min(Math.max(newWidth, MIN_WIDTH), MAX_WIDTH);
|
|
|
|
this.updateWidth(clampedWidth);
|
|
};
|
|
|
|
private handleMouseUp = () => {
|
|
this.isDragging = false;
|
|
this.attachDragListeners(false);
|
|
};
|
|
|
|
private updateWidth(width: number) {
|
|
const bottomBar = this.closest('.bottom-bar');
|
|
|
|
if (bottomBar) {
|
|
(bottomBar as HTMLElement).style.setProperty(
|
|
'--now-playing-width',
|
|
`${width}px`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'now-playing': NowPlaying;
|
|
}
|
|
}
|