Files
yellowjacket/frontend/index.ts
T
logan b5d70ac1cd
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m49s
CI / e2e (pull_request) Successful in 6m12s
feat(explore): offer the autotag match on the album page
The complaint was having to notice the metadata was missing, then go
and hunt the album down on the Autotag page. The album page now says it
while you are looking at the thing: "MusicBrainz has a match for this
album: <release> by <artist>", with Apply tags and Review in Autotag.

Four things about it are load-bearing.

**Applying is offered only where it would do the whole album.** A
tagging group is a folder, so a multi-disc album is several, and one
button that applied to the best-scoring group would leave the album
holding a mix of old and new tags — the exact case the app's Blocking
notification level exists for. `groupCount` is the test, and the answer
there is review rather than apply.

**It rewrites files, so it asks.** `confirmAction()` with an impact
line that says it cannot be undone and that nothing is moved or
deleted, because "rewrites your files" reads worse than it is. The
apply goes through `ApplyAsync`, the registered-job path, so progress
belongs to the jobs indicator and this page does not grow a second one
— what it owes the user is the acknowledgement, because the button is
here. The suggestion clears itself on success rather than inviting a
second click while the job runs.

**The banner does not quote a percentage.** The backend has a score and
deliberately keeps it out of the sentence: 0.95 reads as a probability
and is not one. Which release it is, is the part a person can judge.

**"Review in Autotag" lands on that album.** The queue is sorted by
score so the intended folder is often near the top, and "often" is a
link that sometimes opens a different album. Autotag is a cached
primary view, so there is no construction to hand a payload to: the
request goes on as an attribute and the view *consumes* it, or every
later visit would reopen a folder the user finished with long ago.

`ICON_AUTOTAG` joins the vocabulary at the same time, on the rule
`ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a
suggestion pointing at Autotag wears the Autotag destination's own
mark. It was written inline in the sidebar; two call sites is where a
name stops being one component's detail, so the sweep governs it now.

Verified against the running app with a staged match: the banner, the
confirm dialog's wording, and the navigation landing on the right
folder with the attribute consumed.

Closes #28
2026-08-19 02:34:11 -04:00

634 lines
25 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 { installLongPressContextMenu } from '@utils/long-press';
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();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// ---------------------------------------------------------------------------
// 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'),
// A detail view rather than a primary one on purpose: it is
// somewhere you go and come back from, so the nav stack carries
// the way out (016 B2 phase 2).
'now-playing': () =>
import('@components/now-playing-view/now-playing-view.ts'),
};
// 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;
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);
});
// ---------------------------------------------------------------------------
// The platform's back gesture
// ---------------------------------------------------------------------------
// Android's back button is not a keystroke the page can bind: the
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
// otherwise finishes the activity. This app never touched `history`, so
// that was always false and back quit the app from any depth -- reported
// from a device as "back does not navigate back".
//
// So a navigation is a history entry, and back is `popstate`. It hooks
// the platform's own mechanism rather than a JNI callback of our own,
// which is the same reason `events.ts` hooks the runtime's transport:
// the Java half needs no change, and the behaviour is testable in a
// browser (`page.goBack()`) instead of only on a phone.
//
// Two rules keep the two stacks from disagreeing. A navigation that
// *came from* history pushes nothing (`_isBack`), or going back would
// deepen the stack it is unwinding. And the in-app back buttons --
// `navigate-back`, which the detail views and `now-playing-view` fire --
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** The navigation an entry stands for. `undefined` on the entry that
* predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
/** How many entries this session has pushed beyond that first one --
* i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
history.pushState(state, '');
pushedEntries += 1;
} else {
history.replaceState(state, '');
historyStarted = true;
}
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const nav = (e.state as NavState | null)?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
const view: string = detail.view;
if (!mainContent) return;
const seq = ++navSeq;
if (!detail._isBack) recordNavigation(detail);
// 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) {
// 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 primary view is cached, so there is no construction to
// hand a payload to the way a detail view gets one below. The
// one navigation that carries something is the album page's
// "Review in Autotag", which has to land on *that* album: the
// request goes on as an attribute and `autotag-view` consumes
// it (removes it) once acted on, or every later visit would
// reopen a folder the user finished with long ago.
if (view === 'autotag' && typeof detail.groupKey === 'string') {
target.setAttribute('group-key', detail.groupKey);
}
// 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;
return;
}
await (DETAIL_LOADERS[view]?.() ?? Promise.resolve());
if (seq !== navSeq) return;
// --- Detail (ephemeral) views -----------------------------------------
// 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;
}
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 'now-playing': {
const npEl = document.createElement('now-playing-view');
mainContent.appendChild(npEl);
currentDetailEl = npEl;
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: the in-app back buttons, which are the same press as
// the phone's. It goes through the history rather than a stack of its
// own, so one press is one entry however it arrived -- two stacks is
// how a detail view's own button and the back gesture come to disagree.
//
// At the root there is nothing of ours to go back to, and going back
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
if (pushedEntries > 0) history.back();
});
// 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', '');
}
});
// The button says whether the panel is open, and it learns that
// from the panel rather than from its own click handler.
//
// It is not the only thing that opens the queue -- `now-playing-view`
// sets the same attribute, because it hides the bar this button
// lives in -- so a state kept beside the click would be right until
// something else opened the panel and then quietly wrong. The panel's
// `open` attribute is the one fact; this reflects it.
const reflectQueueState = () => {
queueButton.setAttribute(
'aria-expanded',
String(queuePanel.hasAttribute('open')),
);
};
new MutationObserver(reflectQueueState).observe(queuePanel, {
attributes: true,
attributeFilter: ['open'],
});
reflectQueueState();
// ---------------------------------------------------------------
// 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();