// --------------------------------------------------------------------------- // 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 = { 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 Promise> = { 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 Promise> = { '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> = [ () => 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(); 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 { 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 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 = `

Coming soon: ${view}

`; 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) => { 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();