added genres page

This commit is contained in:
2026-02-23 11:10:21 -05:00
parent a08d59a4a0
commit 588acf09ef
6 changed files with 1386 additions and 8 deletions
+15
View File
@@ -9,6 +9,8 @@ import '@components/library-manager/library-manager.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/search-bar/search-bar.ts';
import '@components/track-details/track-details.ts';
import type { SearchBar } from '@components/search-bar/search-bar.ts';
@@ -69,6 +71,19 @@ document.addEventListener('navigate', (e: Event) => {
mainContent.appendChild(el);
break;
}
case 'genres':
mainContent.innerHTML = '<genres-view></genres-view>';
break;
case 'genre-details': {
const { genreName } =
(e as CustomEvent).detail;
const genreEl = document.createElement('genre-details');
genreEl.setAttribute('genre-name', genreName);
mainContent.innerHTML = '';
mainContent.appendChild(genreEl);
break;
}
case 'libraries':
mainContent.innerHTML = '<library-manager></library-manager>';
break;
@@ -0,0 +1,271 @@
import { LitElement, html, css } from 'lit';
import {
customElement,
property,
state,
} from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
@customElement('genre-details')
export class GenreDetails extends LitElement {
@property({ type: String, attribute: 'genre-name' })
genreName = '';
@state()
private tracks: library.Track[] = [];
@state()
private loading = true;
private libraryCtrl = new LibraryController(this);
private cancelScanComplete?: () => void;
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
/* ====================================
* Header
* ==================================== */
.genre-header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
flex-shrink: 0;
border-bottom: 1px solid
var(
--yj-border-subtle,
rgba(255, 255, 255, 0.06)
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
.genre-avatar {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.genre-avatar .initial {
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 32px;
font-weight: 600;
text-transform: uppercase;
user-select: none;
line-height: 1;
}
.genre-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.genre-title {
font-size: 24px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.2;
}
.track-count {
font-size: 13px;
color: var(
--yj-text-secondary,
#b3b3b3
);
}
/* ====================================
* Content
* ==================================== */
.content {
flex: 1;
overflow: hidden;
}
track-list {
width: 100%;
height: 100%;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadTracks();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelScanComplete?.();
}
/* ================================================================
* Data loading
* ================================================================ */
private async loadTracks() {
if (!this.genreName) return;
try {
const allTracks =
await this.libraryCtrl.getTracks();
this.tracks = (allTracks ?? []).filter(
(t) =>
(t.Genre ?? []).includes(
this.genreName,
),
);
} catch (error) {
console.error(
'Error loading genre tracks:',
error,
);
this.tracks = [];
} finally {
this.loading = false;
}
}
/* ================================================================
* Navigation
* ================================================================ */
private navigateBack() {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'genres' },
}),
);
}
/* ================================================================
* Helpers
* ================================================================ */
private getInitial(name: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
/* ================================================================
* Rendering
* ================================================================ */
override render() {
const trackCount = this.tracks.length;
const trackLabel =
trackCount === 1 ? 'track' : 'tracks';
return html`
<div class="genre-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to genres"
aria-label="Back to genres"
>
<wa-icon
name="arrow-left"
></wa-icon>
</button>
<div class="genre-avatar">
<span class="initial">
${this.getInitial(
this.genreName,
)}
</span>
</div>
<div class="genre-info">
<h1
class="genre-title"
title="${this.genreName}"
>
${this.genreName}
</h1>
${!this.loading
? html`
<span
class="track-count"
>
${trackCount}
${trackLabel}
</span>
`
: ''}
</div>
</div>
<div class="content">
<track-list
.externalTracks=${this.tracks}
></track-list>
</div>
`;
}
}
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks' | 'settings';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'settings';
interface NavItem {
id: View;
@@ -141,6 +141,7 @@ export class AppSidebar extends LitElement {
{ id: 'libraries', label: 'Libraries', icon: 'folder-open' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
{ id: 'artists', label: 'Artists', icon: 'user-group' },
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
@@ -1,6 +1,11 @@
import { library } from '@go/models';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import {
customElement,
property,
state,
query,
} from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
@@ -49,6 +54,15 @@ type SortDirection = 'asc' | 'desc';
@customElement('track-list')
export class TrackList extends LitElement implements SelectionHost {
/**
* When set, the list displays these tracks instead of
* fetching all tracks from the library store. The
* component also skips the LibraryScanComplete listener
* since the parent is responsible for reloading.
*/
@property({ type: Array, attribute: false })
externalTracks?: library.Track[];
private player = new PlayerController(this);
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
@@ -924,11 +938,16 @@ export class TrackList extends LitElement implements SelectionHost {
override connectedCallback() {
super.connectedCallback();
this.restoreSortPreferences();
this.loadTracks();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
if (this.externalTracks) {
this.tracks = this.externalTracks;
} else {
this.loadTracks();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
}
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
document.addEventListener('mousedown', this.mousedownCloseHandler);
@@ -966,6 +985,22 @@ export class TrackList extends LitElement implements SelectionHost {
this.resizeObserver = null;
}
override willUpdate(
changed: Map<PropertyKey, unknown>,
) {
super.willUpdate(changed);
// When the parent provides a new external track
// list, update local tracks and reset selection.
if (
changed.has('externalTracks') &&
this.externalTracks
) {
this.tracks = this.externalTracks;
this.selection.clear();
}
}
override firstUpdated() {
this.initColumnWidths();
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** Searchable views that respond to the global search term. */
const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists']);
const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists', 'genres']);
type Subscriber = () => void;