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`
${announcement}
`; } 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`
${announcement}
${track.coverArt ? html`Album cover { const img = e.target as HTMLImageElement; if ( track.coverArt && img.src !== track.coverArt ) { img.src = track.coverArt; } }} />` : html`
`}
${this.showCoverPreview && track.coverArt ? html`
Album cover full size
` : nothing}
this.onScrollCycleEnd('title')} > ${this.phone ? track.title : trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title} this.onScrollCycleEnd('artist')} > ${this.phone ? track.artist || 'Unknown Artist' : creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'} ${describeQueueSource(this.queue.source) ? html` { if (this.phone) return; if (!isQueueSourceNavigable(this.queue.source)) return; navigateToQueueSource( e.currentTarget as EventTarget, this.queue.source, ); }} >${describeQueueSource(this.queue.source)} ` : nothing}
${track.filePath ? html` ` : nothing}
`; } /** 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; } }