Shuffle, repeat and the queue button leave the phone's bottom bar. They are not gone: all three are on the full-screen Now Playing view, one tap away through the mini player's art, which is the "reachable only from Now Playing" this issue asks for. #55 is what makes the queue half safe -- it is a screen with an entry in the back stack now, rather than a panel with no way out but the button being removed here. Removing a control is only allowed because it is still reachable, which is plan 018's matrix promise, so that is what the spec walks rather than counting buttons. It found that the route did not exist in the state that matters: `now-playing` renders two branches and the no-track one had no `.expand` button on its placeholder, so with nothing loaded there was no way to the full-screen view at all -- and once the queue button left the bar, no way to the queue. The queue is persisted across restarts, so "tracks queued, nothing playing" is a state the app launches into, not a corner. The favourite stays on the bar and was 18x14px, the smallest control in the app, against the 48x48 art beside it. One CSS trap, because it failed silently. The phone block is last in index.css on purpose -- a media query adds no specificity -- but the rule it overrides here is written *nested* inside `.bottom-bar`, so it builds to a descendant selector one class more specific and a bare `#queue-button` lost to it. Being last is not enough when the thing above is more specific. Closes #59
915 lines
31 KiB
TypeScript
915 lines
31 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;
|
|
/* **Above the art, or it is not a target at all** (#150).
|
|
|
|
This button is absolutely positioned with z-index auto and
|
|
the art is a *later* sibling, so the two tie on paint order
|
|
and the later one wins. With an <img> that costs nothing --
|
|
an image is not a hit-test obstacle here -- but a track with
|
|
no artwork renders a placeholder wa-icon, which is, and it
|
|
takes every click aimed at the button underneath it.
|
|
|
|
The failure is therefore per *track*, not per build: on a
|
|
phone the only way into the full-screen now-playing view
|
|
stopped working whenever the current song had no cover.
|
|
Measured with elementFromPoint at the button's centre --
|
|
wa-icon with a placeholder, button.expand with an image, and
|
|
button.expand either way once this line exists.
|
|
|
|
z-index rather than pointer-events: none on the art, which
|
|
would take the cover preview's mouseenter with it; and
|
|
rather than reordering the DOM, which would leave the same
|
|
tie to be won by the same accident in the other direction. */
|
|
z-index: 1;
|
|
}
|
|
|
|
.expand:focus-visible {
|
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* The favourite is one of the three controls #59 keeps on the
|
|
phone's bar, and it was the **smallest control in the app**:
|
|
measured at 424x439, 18x14px, against the 48x48 art beside it.
|
|
Zero padding around an icon-sized glyph is a reasonable mouse
|
|
target and is not a thumb target at all. */
|
|
.fav-btn {
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
font-size: var(--yj-icon-md);
|
|
}
|
|
}
|
|
|
|
.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">
|
|
<!-- **The way to Now Playing does not depend on what is
|
|
playing.** This branch used to render the placeholder
|
|
with no button on it, so on a phone there was no route to
|
|
the full-screen view while nothing was loaded -- and once
|
|
#59 took the queue button off the bar, that made the
|
|
queue itself unreachable, because Now Playing is where it
|
|
is reached from. The queue is persisted across restarts,
|
|
so "a queue with tracks in it and nothing playing" is an
|
|
ordinary state to launch into, not a corner.
|
|
|
|
Plan 018's matrix promises no action is unreachable at
|
|
any supported size, and the promise is what makes #59
|
|
allowed to remove a control at all. -->
|
|
<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">
|
|
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
|
|
</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;
|
|
}
|
|
}
|