The sidebar had a Home item that fell through to "Coming soon". What was missing was not another view of the library — four of those exist, sorted and complete — but the opposite: a complete, sorted library is exactly what gives you nothing to play, because every entry point into it is alphabetical and identical every time you open the app. So a shelf is a *reason*, not a filter. Each one answers a different question you might be asking when you do not know what you want (what was I listening to, what is new, what do I keep coming back to, what have I forgotten, what fits, what would I never pick myself) and each says which question it answered — a row of covers with no explanation is just another grid. Two consequences run through it. Shelves are built from what the user actually did — play counts, last played, import order — with random sampling only where there is no signal to use, so randomness is the fallback rather than the design. And a shelf with nothing behind it is omitted instead of rendered empty: a fresh library legitimately gets three, and an empty row labelled "on repeat" would be a lie. The queries return album ids and nothing else, joined back to GetAllAlbumsWithDetails in Go, so the album projection keeps having one definition rather than one per shelf.
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import '@components/audio-player/audio-player.ts';
|
|
import '@components/track-list/track-list.ts';
|
|
import '@components/cover-grid/cover-grid.ts';
|
|
import '@components/now-playing/now-playing.ts';
|
|
import '@components/sidebar/app-sidebar.ts';
|
|
import '@components/queue-panel/queue-panel.ts';
|
|
import '@components/playlist-view/playlist-view.ts';
|
|
import '@components/config-page/config-page.ts';
|
|
import '@components/artists-view/artists-view.ts';
|
|
import '@components/artist-details/artist-details.ts';
|
|
import '@components/genres-view/genres-view.ts';
|
|
import '@components/genre-details/genre-details.ts';
|
|
import '@components/playlist-details/playlist-details.ts';
|
|
import '@components/smart-playlist-details/smart-playlist-details.ts';
|
|
import '@components/smart-playlist-editor/smart-playlist-editor.ts';
|
|
import '@components/search-bar/search-bar.ts';
|
|
import '@components/library-filter/library-filter.ts';
|
|
import '@components/track-details/track-details.ts';
|
|
import '@components/explore-view/explore-view.ts';
|
|
import '@components/explore-artist-details/explore-artist-details.js';
|
|
import '@components/explore-album-details/explore-album-details.js';
|
|
import '@components/autotag-view/autotag-view.ts';
|
|
import '@components/first-run-wizard/first-run-wizard.ts';
|
|
import '@components/jobs/job-indicator.ts';
|
|
import '@components/jobs/jobs-view.ts';
|
|
import '@components/downloads-view/downloads-view.ts';
|
|
import '@components/home-view/home-view.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 { queueStore } from '@store/queue-store';
|
|
import { searchStore } from '@store/search-store';
|
|
import * as Player from '@go/player/Player';
|
|
import * as Queue from '@go/queue/Queue';
|
|
// 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 {
|
|
hasTrackPayload,
|
|
getDragPayload,
|
|
} from '@utils/drag-controller';
|
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
|
|
|
setBasePath('/dist/webawesome');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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',
|
|
};
|
|
|
|
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: 'tracks' };
|
|
|
|
// Seed the cache with the default track-list rendered in index.html.
|
|
const mainContent = document.getElementById('main-content');
|
|
|
|
if (mainContent) {
|
|
const initialTrackList = mainContent.querySelector('track-list');
|
|
|
|
if (initialTrackList) {
|
|
viewCache.set('tracks', initialTrackList as HTMLElement);
|
|
currentViewEl = initialTrackList as HTMLElement;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('navigate', (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
const view: string = detail.view;
|
|
|
|
if (!mainContent) return;
|
|
|
|
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) {
|
|
currentDetailEl.remove();
|
|
currentDetailEl = null;
|
|
}
|
|
|
|
let target = viewCache.get(view);
|
|
|
|
if (!target) {
|
|
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');
|
|
}
|
|
target.classList.remove('view-hidden');
|
|
currentViewEl = target;
|
|
currentNavDetail = { view };
|
|
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');
|
|
}
|
|
// Remove any prior detail element
|
|
if (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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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 },
|
|
}));
|
|
}
|
|
});
|
|
|
|
// 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();
|