Files
yellowjacket/frontend/src/components/now-playing-view/now-playing-view.ts
T
logan 89882b4863
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m42s
refactor(ui): give the icons one vocabulary and sweep the call sites
`plus` meant "add to the queue", "add to a playlist", "make a new
playlist" and "you do not own this" -- the first two adjacent in the
same context menu, so two neighbouring items were the same glyph doing
different things. `list` meant the queue (the button that opens it), the
Playlists destination, and adding to the queue in `queue-panel` alone.
Two icons carrying seven meanings is not a vocabulary, and nothing
catches it: a wrong-but-real icon renders perfectly.

`utils/icon-language.ts` is the table, beside `library-status.ts` as the
issue suggested. The rule it is built on is that an icon names the
**noun** it acts on, not the verb: "add to queue" and "add to playlist"
are one verb on two nouns, so the noun is what differs -- which is why
adding to a playlist wears the Playlists destination's own icon, and why
the queue took `bars-staggered` and stopped wearing Playlists'. `plus`
keeps the one meaning it is unambiguous about, making something that is
not there yet, which covers New Playlist and the drop zones.

`bars-staggered` is the only new glyph, vendored through names.txt and
fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1.

Two things this found rather than changed:

- The request toggle's outline/solid pair was already in the app and
  already right -- `explore-album-details`'s "Request this" button has
  used `regular/bookmark` -> `solid/bookmark` since it was written --
  while the badge forty pixels away showed a **plus** for the same
  state. That is `utils/library-status.ts`'s fault one layer down: it
  made the two surfaces agree on what wanting *means* and left them
  disagreeing on what it looks like.
- `explore-artist-details`'s Follow button was `bookmark-check`, which
  is Font Awesome **Pro** and has never been bundled, so it has drawn
  the missing-icon fallback -- a circled question mark -- for every
  followed artist since it was written. `requested-badge.spec.ts` was
  written for exactly this bug on the album button and says so in its
  docstring; this is the same bug one component over, still live,
  because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec
  had ever followed an artist.

So the test does what reaching the state cannot. `icon-language.test.ts`
reads every `src/**/*.ts` as raw text and fails on a governed name
written outside the table, and separately asserts every `ICON_*` is a
*bundled* name -- which is what makes a Pro name a failing test rather
than a runtime report from a state something has to reach first. Its
first assertion is that it read any source at all, because a sweep over
an empty glob passes.

`chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned
the badge's glyphs against the vocabulary they were meant to follow; it
names them from the table now, and keeps the assertion that the three
differ, which is the property the states actually need.

Downloads keeps the solid bookmark on purpose. That is one word twice,
not two words: the badge says the entity is on your list and the nav
item is that list.

Closes #34
2026-08-18 21:18:36 -04:00

355 lines
11 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../audio-player/controls/player-controls';
import '../audio-player/seekbar/seek-bar';
import '../audio-player/volume-control/volume-control';
import {
creditLink,
albumLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { creditStore } from '@store/credit-store';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { ICON_QUEUE } from '@utils/icon-language';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
* phase 2).
*
* Phase 1 took the seek bar and the volume out of the bottom bar,
* because 4px of height is not a thumb target and a phone's volume
* belongs to its hardware keys. This is where they went: the same
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
* desktop transport uses, given room. **Not copies of them** — a phone
* layout that reimplements the transport is a second transport to fix
* every bug in, and the seek bar in particular carries the
* interpolation rules that took a plan of their own to get right.
*
* It is a *detail* view rather than a primary one: it is somewhere you
* go and come back from, so `index.ts` pushes the current view onto the
* nav stack and Back pops it. That is also why it is not in the tab
* bar — a tab you cannot leave by pressing the same tab again is not a
* tab.
*/
@customElement('now-playing-view')
export class NowPlayingView extends LitElement {
private player = new PlayerController(this);
/** Unsubscribes the credit-arrival repaint. */
private creditsUnsub?: () => void;
override connectedCallback(): void {
super.connectedCallback();
// Credits arrive after the track does, so the name this view is
// already showing has to be re-rendered when they land.
this.creditsUnsub = creditStore.subscribe(() => this.requestUpdate());
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.creditsUnsub?.();
this.creditsUnsub = undefined;
}
private favCtrl = new FavoritesController(this);
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0.75em 1em 1.25em;
gap: 0.75em;
background-color: var(--yj-bg-surface, #212529);
overflow-y: auto;
}
header {
display: flex;
align-items: center;
gap: 0.5em;
flex: 0 0 auto;
}
.context {
flex: 1 1 auto;
}
.back {
background: none;
border: none;
color: var(--yj-text-primary, #f8f9fa);
/* 48px is the touch-target floor, and this is the control
that gets a user out of a full-screen view. */
min-width: 48px;
min-height: 48px;
font-size: 1.1rem;
cursor: pointer;
border-radius: 6px;
}
.back:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.context {
font-size: var(--yj-font-size-xs, 0.75rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yj-text-secondary, #adb5bd);
}
.art {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
.art .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--yj-text-tertiary, #868e96);
}
.meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.75em;
min-width: 0;
}
.names {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0;
/* Two lines, then an ellipsis. A marquee is the bottom
bar's answer to a 320px box; here there is room to wrap,
and wrapping does not move. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist,
.album {
margin: 0;
font-size: 0.9rem;
color: var(--yj-text-secondary, #adb5bd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite {
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
min-width: 48px;
min-height: 48px;
font-size: 1.25rem;
cursor: pointer;
border-radius: 6px;
}
.favorite.on {
color: var(--yj-accent, #ffd43b);
}
.favorite:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.transport {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The seek bar is the reason this view exists. Its own
stylesheet thickens the track below the phone breakpoint --
the track size is set on the wa-slider inside its shadow
root, so a custom property set from here would not reach
it. */
seek-bar {
display: block;
}
.empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
`];
private back() {
this.dispatchEvent(new CustomEvent('navigate-back', {
bubbles: true,
composed: true,
}));
}
/**
* Open the queue.
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
}
private toggleFavorite() {
const path = this.player.currentTrack?.filePath;
if (path) void this.favCtrl.toggleFavorite(path);
}
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
${this.renderHeader()}
<p class="empty" data-testid="npv-empty">
Nothing is playing.
</p>
`;
}
const favorited = this.favCtrl.isFavorited(track.filePath);
// The largest kept tier, which is what `saveCoverArt` records as
// the path -- there is no full-resolution original to reach for.
const art = track.coverArtLarge || track.coverArt;
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<volume-control></volume-control>
</div>
`;
}
private renderHeader() {
return html`
<header>
<button
type="button"
class="back"
data-testid="npv-back"
aria-label="Back"
@click=${this.back}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<span class="context">Now playing</span>
<button
type="button"
class="back"
data-testid="npv-queue"
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name=${ICON_QUEUE}></wa-icon>
</button>
</header>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'now-playing-view': NowPlayingView;
}
}