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 | 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 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`
`;
}
/** 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('.track-title');
const artistEl = this.shadowRoot?.querySelector('.track-artist');
const measure = (el: HTMLElement | null | undefined) => {
if (!el) return { overflows: false, distance: 0 };
const content = el.querySelector('.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(
'#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;
}
}