Files
yellowjacket/frontend/index.ts
T
logan 57fbbdf0d2
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00

531 lines
20 KiB
TypeScript

// ---------------------------------------------------------------------------
// What is imported here is what is parsed and evaluated before first
// paint. Everything else is a chunk fetched on the navigation that
// needs it (VIEW_LOADERS / DETAIL_LOADERS below) and warmed in the
// background once the app is idle, so only the *first* second of the
// session pays for the views the user has not asked for.
//
// Three things stay eager that look like candidates:
//
// notification-host, inline-notice, confirm-dialog — the app's only
// failure surface. A message that has to fetch a chunk before it can
// be shown is not a failure surface; the moment it is most needed is
// exactly the moment loading one may not work.
//
// first-run-wizard — it covers the app until a library exists, so on
// the one launch it matters it is on the critical path anyway.
//
// track-list — index.html renders one, so it is the first paint.
// ---------------------------------------------------------------------------
import '@components/audio-player/audio-player.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
import '@components/notifications/inline-notice.ts';
import '@components/confirm-dialog/confirm-dialog.ts';
// The `?` overlay: help, so it is eager for the same reason the failure
// surface is — the moment it is asked for is the moment the user does
// not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
// Importing the theme store triggers initialization: it fetches the saved
// theme from the backend and applies CSS custom properties to :root.
import '@store/theme-store';
// Importing the keyboard shortcut service triggers initialization:
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import {
hasTrackPayload,
getDragPayload,
} from '@utils/drag-controller';
import type { DragActiveDetail } from '@utils/drag-controller';
setBasePath('/dist/webawesome');
// Before any component renders: an icon resolved by the default
// (remote) library is a request to fontawesome.com, and the module
// caches by URL, so one early render would pin the remote answer for
// the session.
registerBundledIcons();
// ---------------------------------------------------------------------------
// View caching navigation system
// ---------------------------------------------------------------------------
// Primary views (tracks, albums, artists, genres, playlists, settings) are
// created once and kept alive in the DOM. Navigation toggles visibility
// (display: none ↔ display: '') instead of destroying/recreating via
// innerHTML. Detail views (artist-details, playlist-details, genre-details)
// are ephemeral — created fresh each navigation because they depend on
// specific entity IDs that change.
//
// Because a cached view is never disconnected, `disconnectedCallback` is
// not where it stops listening. Navigation calls viewDeactivated() on
// the outgoing element and viewActivated() on the incoming one; views
// hang their document listeners, timers and subscriptions off that pair
// (see utils/view-lifecycle.ts). Skipping either call leaves a view
// listening from a page it is not on, which is finding H-1.
// ---------------------------------------------------------------------------
const VIEW_TAGS: Record<string, string> = {
home: 'home-view',
tracks: 'track-list',
albums: 'cover-grid',
artists: 'artists-view',
genres: 'genres-view',
playlists: 'playlist-view',
explore: 'explore-view',
autotag: 'autotag-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
settings: 'config-page',
};
// The module that defines each view's custom element. `createElement`
// on an undefined tag silently produces an inert HTMLElement rather
// than throwing, so a navigation has to await its loader before it
// builds anything — a missing entry here is a blank page, not an error.
const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
home: () => import('@components/home-view/home-view.ts'),
tracks: () => Promise.resolve(),
albums: () => import('@components/cover-grid/cover-grid.ts'),
artists: () => import('@components/artists-view/artists-view.ts'),
genres: () => import('@components/genres-view/genres-view.ts'),
playlists: () => import('@components/playlist-view/playlist-view.ts'),
explore: () => import('@components/explore-view/explore-view.ts'),
autotag: () => import('@components/autotag-view/autotag-view.ts'),
downloads: () => import('@components/downloads-view/downloads-view.ts'),
jobs: () => import('@components/jobs/jobs-view.ts'),
settings: () => import('@components/config-page/config-page.ts'),
};
const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
'artist-details': () =>
import('@components/artist-details/artist-details.ts'),
'playlist-details': () =>
import('@components/playlist-details/playlist-details.ts'),
'smart-playlist-details': () =>
import('@components/smart-playlist-details/smart-playlist-details.ts'),
'genre-details': () =>
import('@components/genre-details/genre-details.ts'),
'explore-artist-details': () =>
import('@components/explore-artist-details/explore-artist-details.js'),
'explore-album-details': () =>
import('@components/explore-album-details/explore-album-details.js'),
};
// Opened from a menu rather than by navigating, so they have no entry
// above; warmed with everything else below.
//
// `track-details` is loaded at the point of use by
// `utils/lazy-track-details.ts`, from the five components that open it
// (`track-list`, `cover-grid`, `queue-panel` and both playlist detail
// views). It used to be imported statically by all five, so its 42 kB
// rode in the startup chunk whatever this file said. Warming it here
// means the first open still does not wait for it.
const EXTRA_LOADERS: Array<() => Promise<unknown>> = [
() => import('@components/smart-playlist-editor/smart-playlist-editor.ts'),
// Same specifier as `utils/lazy-track-details.ts` uses, so this is
// the same chunk rather than a second copy of it.
() => import('@components/track-details/track-details.js'),
];
const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
const mainContent = document.getElementById('main-content');
// Seed the cache with the default track-list rendered in index.html —
// otherwise the very first navigation (to whatever GetDefaultPage
// resolves to) creates and shows a second view while this one, never
// tracked as currentViewEl, is never hidden: two visible primary views
// splitting the main panel between them regardless of which is
// selected.
if (mainContent) {
const initialTrackList = mainContent.querySelector('track-list');
if (initialTrackList) {
viewCache.set('tracks', initialTrackList as HTMLElement);
currentViewEl = initialTrackList as HTMLElement;
activateView(currentViewEl);
}
}
/**
* Navigations are numbered, because loading a view's chunk is
* asynchronous and a user can click twice. Anything after the `await`
* checks that it is still the newest navigation before touching the
* DOM; otherwise a slow chunk would land on top of a faster one and
* show the page the user navigated *away* from.
*/
let navSeq = 0;
document.addEventListener('navigate', (e: Event) => {
void handleNavigate((e as CustomEvent).detail);
});
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
const view: string = detail.view;
if (!mainContent) return;
const seq = ++navSeq;
// Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e
// selectors read.
searchStore.setCurrentView(view);
// Which view is showing is otherwise only inferable from which of
// the cached children lacks .view-hidden. Publishing it as an
// attribute keeps e2e selectors semantic instead of structural.
mainContent.dataset.activeView = view;
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Navigating to a primary view clears the history stack.
navStack.length = 0;
// Remove any active detail view first
if (currentDetailEl) {
deactivateView(currentDetailEl);
currentDetailEl.remove();
currentDetailEl = null;
}
let target = viewCache.get(view);
if (!target) {
await (VIEW_LOADERS[view]?.() ?? Promise.resolve());
if (seq !== navSeq) return;
target = document.createElement(VIEW_TAGS[view]);
viewCache.set(view, target);
// Start hidden — we'll un-hide below
target.classList.add('view-hidden');
mainContent.appendChild(target);
}
// Hide current, show target. Uses CSS class instead of
// display:none so scroll containers preserve scrollTop.
if (currentViewEl && currentViewEl !== target) {
currentViewEl.classList.add('view-hidden');
deactivateView(currentViewEl);
}
target.classList.remove('view-hidden');
// A freshly created view was appended hidden, so it did not
// self-activate on connection; a cached one was deactivated on
// the way out. Either way this is the call that starts it.
activateView(target);
currentViewEl = target;
currentNavDetail = { view };
return;
}
await (DETAIL_LOADERS[view]?.() ?? Promise.resolve());
if (seq !== navSeq) return;
// --- Detail (ephemeral) views -----------------------------------------
// Push the current view onto the nav stack before switching
// (unless this is a back-navigation, which already popped).
if (!detail._isBack) {
navStack.push({ ...currentNavDetail });
}
// Hide the current primary view
if (currentViewEl) {
currentViewEl.classList.add('view-hidden');
deactivateView(currentViewEl);
}
// Remove any prior detail element
if (currentDetailEl) {
deactivateView(currentDetailEl);
currentDetailEl.remove();
currentDetailEl = null;
}
currentNavDetail = { ...detail };
switch (view) {
case 'artist-details': {
const { artistId, artistName } = detail;
const el = document.createElement('artist-details');
el.setAttribute('artist-id', String(artistId));
el.setAttribute('artist-name', artistName);
mainContent.appendChild(el);
currentDetailEl = el;
break;
}
case 'playlist-details': {
const { playlistId, playlistName } = detail;
const plEl = document.createElement('playlist-details');
plEl.setAttribute('playlist-id', String(playlistId));
plEl.setAttribute('playlist-name', playlistName);
mainContent.appendChild(plEl);
currentDetailEl = plEl;
break;
}
case 'smart-playlist-details': {
const { playlistId, playlistName } = detail;
const spEl = document.createElement('smart-playlist-details');
spEl.setAttribute('playlist-id', String(playlistId));
spEl.setAttribute('playlist-name', playlistName);
if (detail.autoEdit) {
spEl.setAttribute('auto-edit', '');
}
mainContent.appendChild(spEl);
currentDetailEl = spEl;
break;
}
case 'genre-details': {
const { genreName } = detail;
const genreEl = document.createElement('genre-details');
genreEl.setAttribute('genre-name', genreName);
mainContent.appendChild(genreEl);
currentDetailEl = genreEl;
break;
}
case 'explore-artist-details': {
const { artistMBID, artistName, localArtistId } = detail;
const el = document.createElement('explore-artist-details');
if (artistMBID) el.setAttribute('artist-mbid', artistMBID);
el.setAttribute('artist-name', artistName);
if (localArtistId) el.setAttribute('local-artist-id', String(localArtistId));
mainContent.appendChild(el);
currentDetailEl = el;
break;
}
case 'explore-album-details': {
const {
releaseGroupMBID,
albumName,
artistName,
highlightTrackMBID,
highlightTrackTitle,
localAlbumId,
} = detail;
const el = document.createElement('explore-album-details');
if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
el.setAttribute('album-name', albumName);
if (artistName) el.setAttribute('artist-name', artistName);
if (highlightTrackMBID) {
el.setAttribute('highlight-track-mbid', highlightTrackMBID);
}
if (highlightTrackTitle) {
el.setAttribute('highlight-track-title', highlightTrackTitle);
}
if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
mainContent.appendChild(el);
currentDetailEl = el;
break;
}
default: {
const fallback = document.createElement('div');
fallback.style.padding = '1em';
fallback.style.color = 'var(--yj-text-secondary, #b3b3b3)';
fallback.innerHTML = `<p>Coming soon: ${view}</p>`;
mainContent.appendChild(fallback);
currentDetailEl = fallback;
}
}
}
// Every view the user has not opened yet, fetched once the app has
// settled. Splitting keeps them off the path to first paint; warming
// them means the navigation that needs one almost never waits, which is
// the cost a naive split would have traded the startup win for.
function warmViewChunks(): void {
const loaders = [
...Object.values(VIEW_LOADERS),
...Object.values(DETAIL_LOADERS),
...EXTRA_LOADERS,
];
const warmNext = (i: number): void => {
if (i >= loaders.length) return;
void loaders[i]!()
.catch(() => {
// A chunk that will not preload is not a failure: the
// navigation that needs it will ask again and report
// for itself if it still cannot be had.
})
.finally(() => {
schedule(() => warmNext(i + 1));
});
};
schedule(() => warmNext(0));
}
/** requestIdleCallback where it exists; WebKit2GTK does not have it. */
function schedule(fn: () => void): void {
const ric = (
window as unknown as {
requestIdleCallback?: (cb: () => void) => number;
}
).requestIdleCallback;
if (ric) {
ric(fn);
return;
}
setTimeout(fn, 200);
}
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
document.addEventListener('navigate-back', () => {
const prev = navStack.pop();
if (prev) {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { ...prev, _isBack: true },
}));
}
});
// Navigate to the user's configured launch page. Falls back to 'home'
// if the backend call fails, matching the config's own default.
GetDefaultPage()
.then((view) => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: view || 'home' },
}));
})
.catch(() => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'home' },
}));
});
// Queue panel toggle
const queueButton = document.getElementById('queue-button');
const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
if (queueButton && queuePanel) {
queueButton.addEventListener('click', () => {
const isOpen = queuePanel.hasAttribute('open');
if (isOpen) {
queuePanel.removeAttribute('open');
} else {
queuePanel.setAttribute('open', '');
}
});
// ---------------------------------------------------------------
// Queue button as drop target (when queue panel is closed)
// ---------------------------------------------------------------
queueButton.addEventListener('dragover', (e: DragEvent) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
queueButton.classList.add('drag-over');
});
queueButton.addEventListener('dragleave', () => {
queueButton.classList.remove('drag-over');
});
queueButton.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
queueButton.classList.remove('drag-over');
const payload = getDragPayload(e);
if (!payload || payload.filePaths.length === 0) return;
if (payload.source === 'queue') return;
queueStore.addTracksToQueue(payload.filePaths);
});
// Show/hide drag-over styling globally.
document.addEventListener(
'yj-drag-active',
((e: CustomEvent<DragActiveDetail>) => {
if (!e.detail.active) {
queueButton.classList.remove('drag-over');
}
}) as EventListener,
);
}
// ---------------------------------------------------------------
// Request current state from the backend
// ---------------------------------------------------------------
// All stores have registered their EventsOn listeners by now
// (module-level singletons are instantiated during import
// evaluation), so the state-push events emitted by these
// binding calls will be received deterministically — no sleep
// or timing assumptions needed.
void Player.EmitCurrentState();
void Queue.EmitCurrentState();
// ---------------------------------------------------------------
// Land on Home
// ---------------------------------------------------------------
// The app opened on Tracks — an alphabetical list of everything, which
// is the one entry point that is identical every time and therefore
// gives the user nothing to start from. Home is listed first in the nav
// and is the page built to answer "what should I play", and it was
// never what anybody saw (H-8).
//
// index.html still renders the track list eagerly and it is still what
// paints first: it is the cached 'tracks' view, so this navigation is a
// class toggle plus one chunk, not a second render of the shell. Doing
// it here rather than by changing the markup keeps the first paint
// exactly as Phase 4 left it.
document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: 'home' },
}),
);
warmViewChunks();