added genres page
This commit is contained in:
@@ -9,6 +9,8 @@ import '@components/library-manager/library-manager.ts';
|
|||||||
import '@components/config-page/config-page.ts';
|
import '@components/config-page/config-page.ts';
|
||||||
import '@components/artists-view/artists-view.ts';
|
import '@components/artists-view/artists-view.ts';
|
||||||
import '@components/artist-details/artist-details.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/search-bar/search-bar.ts';
|
||||||
import '@components/track-details/track-details.ts';
|
import '@components/track-details/track-details.ts';
|
||||||
import type { SearchBar } from '@components/search-bar/search-bar.ts';
|
import type { SearchBar } from '@components/search-bar/search-bar.ts';
|
||||||
@@ -69,6 +71,19 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
mainContent.appendChild(el);
|
mainContent.appendChild(el);
|
||||||
break;
|
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':
|
case 'libraries':
|
||||||
mainContent.innerHTML = '<library-manager></library-manager>';
|
mainContent.innerHTML = '<library-manager></library-manager>';
|
||||||
break;
|
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';
|
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 {
|
interface NavItem {
|
||||||
id: View;
|
id: View;
|
||||||
@@ -141,6 +141,7 @@ export class AppSidebar extends LitElement {
|
|||||||
{ id: 'libraries', label: 'Libraries', icon: 'folder-open' },
|
{ id: 'libraries', label: 'Libraries', icon: 'folder-open' },
|
||||||
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
||||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||||
|
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
||||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { library } from '@go/models';
|
import { library } from '@go/models';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
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 { EventsOn } from '@runtime/runtime';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } from '@utils/selection-controller';
|
import type { SelectionHost } from '@utils/selection-controller';
|
||||||
@@ -49,6 +54,15 @@ type SortDirection = 'asc' | 'desc';
|
|||||||
|
|
||||||
@customElement('track-list')
|
@customElement('track-list')
|
||||||
export class TrackList extends LitElement implements SelectionHost {
|
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 player = new PlayerController(this);
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
@@ -924,11 +938,16 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.restoreSortPreferences();
|
this.restoreSortPreferences();
|
||||||
this.loadTracks();
|
|
||||||
this.cancelScanComplete = EventsOn(
|
if (this.externalTracks) {
|
||||||
Events.LibraryScanComplete,
|
this.tracks = this.externalTracks;
|
||||||
() => this.loadTracks(),
|
} else {
|
||||||
);
|
this.loadTracks();
|
||||||
|
this.cancelScanComplete = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadTracks(),
|
||||||
|
);
|
||||||
|
}
|
||||||
document.addEventListener('click', this.closeHandler);
|
document.addEventListener('click', this.closeHandler);
|
||||||
document.addEventListener('contextmenu', this.closeHandler);
|
document.addEventListener('contextmenu', this.closeHandler);
|
||||||
document.addEventListener('mousedown', this.mousedownCloseHandler);
|
document.addEventListener('mousedown', this.mousedownCloseHandler);
|
||||||
@@ -966,6 +985,22 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
this.resizeObserver = null;
|
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() {
|
override firstUpdated() {
|
||||||
this.initColumnWidths();
|
this.initColumnWidths();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Searchable views that respond to the global search term. */
|
/** 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;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user