beginnings of artist grid and artist details view
This commit is contained in:
@@ -30,3 +30,11 @@ DELETE FROM artists;
|
|||||||
-- name: GetAllArtists :many
|
-- name: GetAllArtists :many
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists
|
||||||
ORDER BY name;
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: GetAlbumArtists :many
|
||||||
|
SELECT DISTINCT a.id, a.name
|
||||||
|
FROM artists a
|
||||||
|
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
||||||
|
JOIN artist_credit ac ON ac.id = aca.credit_id
|
||||||
|
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
||||||
|
ORDER BY a.name;
|
||||||
|
|||||||
@@ -63,3 +63,24 @@ LEFT JOIN (
|
|||||||
GROUP BY rgr.release_group_id
|
GROUP BY rgr.release_group_id
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||||
ORDER BY rg.name;
|
ORDER BY rg.name;
|
||||||
|
|
||||||
|
-- name: GetAlbumsByArtist :many
|
||||||
|
SELECT
|
||||||
|
rg.id,
|
||||||
|
rg.name,
|
||||||
|
rg.year,
|
||||||
|
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||||
|
COALESCE(ca.file_path, '') as cover_art_path
|
||||||
|
FROM release_groups rg
|
||||||
|
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||||
|
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||||
|
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT rgr.release_group_id, ac2.text
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||||
|
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||||
|
GROUP BY rgr.release_group_id
|
||||||
|
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||||
|
WHERE aca.artist_id = ?
|
||||||
|
ORDER BY rg.name;
|
||||||
|
|||||||
@@ -40,6 +40,38 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAlbumArtists = `-- name: GetAlbumArtists :many
|
||||||
|
SELECT DISTINCT a.id, a.name
|
||||||
|
FROM artists a
|
||||||
|
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
||||||
|
JOIN artist_credit ac ON ac.id = aca.credit_id
|
||||||
|
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
||||||
|
ORDER BY a.name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAlbumArtists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Artist
|
||||||
|
for rows.Next() {
|
||||||
|
var i Artist
|
||||||
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getAllArtists = `-- name: GetAllArtists :many
|
const getAllArtists = `-- name: GetAllArtists :many
|
||||||
SELECT id, name FROM artists
|
SELECT id, name FROM artists
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
|
|||||||
@@ -87,6 +87,65 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAlbumsByArtist = `-- name: GetAlbumsByArtist :many
|
||||||
|
SELECT
|
||||||
|
rg.id,
|
||||||
|
rg.name,
|
||||||
|
rg.year,
|
||||||
|
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||||
|
COALESCE(ca.file_path, '') as cover_art_path
|
||||||
|
FROM release_groups rg
|
||||||
|
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||||
|
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||||
|
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT rgr.release_group_id, ac2.text
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||||
|
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||||
|
GROUP BY rgr.release_group_id
|
||||||
|
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||||
|
WHERE aca.artist_id = ?
|
||||||
|
ORDER BY rg.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumsByArtistRow struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Year sql.NullInt64
|
||||||
|
ArtistName string
|
||||||
|
CoverArtPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetAlbumsByArtistRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAlbumsByArtist, artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetAlbumsByArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetAlbumsByArtistRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Year,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.CoverArtPath,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
|
const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
|
||||||
SELECT
|
SELECT
|
||||||
rg.id,
|
rg.id,
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ func splitGenres(concatenated string) []string {
|
|||||||
return strings.Split(concatenated, genreDelimiter)
|
return strings.Split(concatenated, genreDelimiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Artist represents an artist in the library.
|
||||||
|
type Artist struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
// Album represents an album for the cover grid display.
|
// Album represents an album for the cover grid display.
|
||||||
type Album struct {
|
type Album struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -177,3 +183,90 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
|
|||||||
|
|
||||||
return albums, nil
|
return albums, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllArtists returns artists that are credited as album artists, ordered by name.
|
||||||
|
func (l *Library) GetAllArtists() ([]Artist, error) {
|
||||||
|
rows, err := l.db.Queries.GetAlbumArtists(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve artists",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not get artists: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.logger.Info("artist list", "count", len(rows))
|
||||||
|
|
||||||
|
artists := make([]Artist, 0, len(rows))
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
artists = append(artists, Artist{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return artists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumsByArtist returns all albums where the given artist is the album artist.
|
||||||
|
func (l *Library) GetAlbumsByArtist(
|
||||||
|
artistID int64,
|
||||||
|
) ([]Album, error) {
|
||||||
|
rows, err := l.db.Queries.GetAlbumsByArtist(
|
||||||
|
l.ctx,
|
||||||
|
artistID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve albums for artist",
|
||||||
|
"artistID", artistID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not get albums for artist: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.logger.Info(
|
||||||
|
"albums for artist",
|
||||||
|
"artistID", artistID,
|
||||||
|
"count", len(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
albums := make([]Album, 0, len(rows))
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
album := Album{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
ArtistName: row.ArtistName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Year.Valid {
|
||||||
|
album.Year = row.Year.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert filesystem path to URL path for the asset handler.
|
||||||
|
if row.CoverArtPath != "" {
|
||||||
|
base := filepath.Base(row.CoverArtPath)
|
||||||
|
album.CoverArtPath = "/covers/" + base
|
||||||
|
album.CoverArtSmall = "/covers/" +
|
||||||
|
SizedFilename(base, "_sm")
|
||||||
|
album.CoverArtMedium = "/covers/" +
|
||||||
|
SizedFilename(base, "_md")
|
||||||
|
album.CoverArtLarge = "/covers/" +
|
||||||
|
SizedFilename(base, "_lg")
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
return albums, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '@components/queue-panel/queue-panel.ts';
|
|||||||
import '@components/playlist-view/playlist-view.ts';
|
import '@components/playlist-view/playlist-view.ts';
|
||||||
import '@components/library-manager/library-manager.ts';
|
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/artist-details/artist-details.ts';
|
||||||
import '@components/search-bar/search-bar.ts';
|
import '@components/search-bar/search-bar.ts';
|
||||||
import type { SearchBar } from '@components/search-bar/search-bar.ts';
|
import type { SearchBar } from '@components/search-bar/search-bar.ts';
|
||||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
@@ -52,6 +54,20 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
case 'playlists':
|
case 'playlists':
|
||||||
mainContent.innerHTML = '<playlist-view></playlist-view>';
|
mainContent.innerHTML = '<playlist-view></playlist-view>';
|
||||||
break;
|
break;
|
||||||
|
case 'artists':
|
||||||
|
mainContent.innerHTML = '<artists-view></artists-view>';
|
||||||
|
break;
|
||||||
|
case 'artist-details': {
|
||||||
|
const { artistId, artistName } =
|
||||||
|
(e as CustomEvent).detail;
|
||||||
|
const el = document.createElement('artist-details');
|
||||||
|
|
||||||
|
el.setAttribute('artist-id', String(artistId));
|
||||||
|
el.setAttribute('artist-name', artistName);
|
||||||
|
mainContent.innerHTML = '';
|
||||||
|
mainContent.appendChild(el);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'libraries':
|
case 'libraries':
|
||||||
mainContent.innerHTML = '<library-manager></library-manager>';
|
mainContent.innerHTML = '<library-manager></library-manager>';
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
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/cover-grid/cover-grid.js';
|
||||||
|
|
||||||
|
@customElement('artist-details')
|
||||||
|
export class ArtistDetails extends LitElement {
|
||||||
|
@property({ type: Number, attribute: 'artist-id' })
|
||||||
|
artistId = 0;
|
||||||
|
|
||||||
|
@property({ type: String, attribute: 'artist-name' })
|
||||||
|
artistName = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private albums: library.Album[] = [];
|
||||||
|
|
||||||
|
@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
|
||||||
|
* ==================================== */
|
||||||
|
|
||||||
|
.artist-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-avatar .initial {
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================
|
||||||
|
* Content
|
||||||
|
* ==================================== */
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
cover-grid {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message,
|
||||||
|
.empty-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.loadAlbums();
|
||||||
|
this.cancelScanComplete = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadAlbums(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.cancelScanComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Data loading
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private async loadAlbums() {
|
||||||
|
if (!this.artistId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
const albums =
|
||||||
|
await this.libraryCtrl.getAlbumsByArtist(
|
||||||
|
this.artistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.albums = albums ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Error loading artist albums:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.albums = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Navigation
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private navigateBack() {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: { view: 'artists' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Helpers
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private getInitial(name: string): string {
|
||||||
|
if (!name) return '?';
|
||||||
|
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Rendering
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const albumCount = this.albums.length;
|
||||||
|
const albumLabel =
|
||||||
|
albumCount === 1 ? 'album' : 'albums';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="artist-header">
|
||||||
|
<button
|
||||||
|
class="back-button"
|
||||||
|
@click=${this.navigateBack}
|
||||||
|
title="Back to artists"
|
||||||
|
aria-label="Back to artists"
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name="arrow-left"
|
||||||
|
></wa-icon>
|
||||||
|
</button>
|
||||||
|
<div class="artist-avatar">
|
||||||
|
<span class="initial">
|
||||||
|
${this.getInitial(
|
||||||
|
this.artistName,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="artist-info">
|
||||||
|
<h1
|
||||||
|
class="artist-title"
|
||||||
|
title="${this.artistName}"
|
||||||
|
>
|
||||||
|
${this.artistName}
|
||||||
|
</h1>
|
||||||
|
${!this.loading
|
||||||
|
? html`
|
||||||
|
<span
|
||||||
|
class="album-count"
|
||||||
|
>
|
||||||
|
${albumCount}
|
||||||
|
${albumLabel}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
${this.loading
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="loading-message"
|
||||||
|
>
|
||||||
|
Loading albums...
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<cover-grid
|
||||||
|
.externalAlbums=${this
|
||||||
|
.albums}
|
||||||
|
></cover-grid>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
import { EventsOn } from '@runtime/runtime';
|
||||||
|
import '@lit-labs/virtualizer';
|
||||||
|
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||||
|
import { library } from '@go/models';
|
||||||
|
import { LibraryController } from '@store/controllers/library-controller';
|
||||||
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
|
import { Events } from '../../events';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
|
||||||
|
/** Pixels to change card width per scroll tick. */
|
||||||
|
const ZOOM_STEP = 16;
|
||||||
|
|
||||||
|
/** localStorage key for persisted artist card size. */
|
||||||
|
const CARD_SIZE_KEY = 'artists-view-card-size';
|
||||||
|
|
||||||
|
/** Card size limits. */
|
||||||
|
const CARD_SIZE_MIN = 100;
|
||||||
|
const CARD_SIZE_MAX = 350;
|
||||||
|
const CARD_SIZE_DEFAULT = 176;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid entry for the virtualized artist grid.
|
||||||
|
*/
|
||||||
|
interface ArtistEntry {
|
||||||
|
artist: library.Artist;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('artists-view')
|
||||||
|
export class ArtistsView extends LitElement {
|
||||||
|
private libraryCtrl = new LibraryController(this);
|
||||||
|
private searchCtrl = new SearchController(this);
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
|
private wheelListenerAttached = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private artists: library.Artist[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private loading = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private cardSize: number = CARD_SIZE_DEFAULT;
|
||||||
|
|
||||||
|
// Fixed grid spacing constants.
|
||||||
|
private static readonly GRID_GAP = 8;
|
||||||
|
private static readonly GRID_PADDING = 8;
|
||||||
|
private static readonly CARD_PADDING = 5;
|
||||||
|
|
||||||
|
private get imageSize(): number {
|
||||||
|
return this.cardSize - ArtistsView.CARD_PADDING * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get cardTextHeight(): number {
|
||||||
|
const w = this.cardSize;
|
||||||
|
|
||||||
|
if (w < 160) return 30;
|
||||||
|
if (w > 250) return 42;
|
||||||
|
|
||||||
|
return 36;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wheel handler reference for add/remove. */
|
||||||
|
private wheelHandler = (e: WheelEvent) => {
|
||||||
|
this.onWheel(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
private gridLayout = this.createGridLayout();
|
||||||
|
|
||||||
|
private createGridLayout() {
|
||||||
|
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
|
||||||
|
const h = w + this.cardTextHeight;
|
||||||
|
const gap = ArtistsView.GRID_GAP;
|
||||||
|
const pad = ArtistsView.GRID_PADDING;
|
||||||
|
|
||||||
|
return grid({
|
||||||
|
itemSize: {
|
||||||
|
width: `${w}px`,
|
||||||
|
height: `${h}px`,
|
||||||
|
},
|
||||||
|
gap: `${gap}px`,
|
||||||
|
padding: `${pad}px`,
|
||||||
|
justify: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtered artists based on search term. */
|
||||||
|
private get filteredArtists(): library.Artist[] {
|
||||||
|
const term =
|
||||||
|
this.searchCtrl.term.toLowerCase();
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return this.artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.artists.filter((a) =>
|
||||||
|
a.Name.toLowerCase().includes(term),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build grid entries from filtered artists. */
|
||||||
|
private get gridEntries(): ArtistEntry[] {
|
||||||
|
return this.filteredArtists.map(
|
||||||
|
(artist, index) => ({
|
||||||
|
artist,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-scroll-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
lit-virtualizer {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
transform 0.1s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card:hover {
|
||||||
|
background-color: var(
|
||||||
|
--yj-bg-overlay,
|
||||||
|
rgba(255, 255, 255, 0.06)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
width: var(--avatar-size);
|
||||||
|
height: var(--avatar-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
font-size: var(--placeholder-font, 48px);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--artist-name-font, 14px);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: var(--artist-name-pad, 6px) 2px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message,
|
||||||
|
.empty-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.loadCardSize();
|
||||||
|
this.loadArtists();
|
||||||
|
this.cancelScanComplete = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadArtists(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.cancelScanComplete?.();
|
||||||
|
this.detachWheelListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated() {
|
||||||
|
this.updateSizeProperties();
|
||||||
|
this.ensureWheelListener();
|
||||||
|
this.updateGridLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Data loading
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private async loadArtists() {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
const artists =
|
||||||
|
await this.libraryCtrl.getArtists();
|
||||||
|
|
||||||
|
this.artists = artists ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Error loading artists:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.artists = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Card size (zoom)
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private loadCardSize(): void {
|
||||||
|
try {
|
||||||
|
const stored =
|
||||||
|
localStorage.getItem(CARD_SIZE_KEY);
|
||||||
|
|
||||||
|
if (stored !== null) {
|
||||||
|
const parsed = parseInt(stored, 10);
|
||||||
|
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
this.cardSize = Math.max(
|
||||||
|
CARD_SIZE_MIN,
|
||||||
|
Math.min(
|
||||||
|
CARD_SIZE_MAX,
|
||||||
|
parsed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveCardSize(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
CARD_SIZE_KEY,
|
||||||
|
String(this.cardSize),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCardSize(size: number): void {
|
||||||
|
const clamped = Math.round(
|
||||||
|
Math.max(
|
||||||
|
CARD_SIZE_MIN,
|
||||||
|
Math.min(CARD_SIZE_MAX, size),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clamped === this.cardSize) return;
|
||||||
|
|
||||||
|
this.cardSize = clamped;
|
||||||
|
this.saveCardSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Wheel zoom (Ctrl+scroll)
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private onWheel(e: WheelEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const delta =
|
||||||
|
e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
|
||||||
|
|
||||||
|
this.setCardSize(this.cardSize + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureWheelListener() {
|
||||||
|
const container = this.shadowRoot?.querySelector(
|
||||||
|
'.grid-scroll-container',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (container && !this.wheelListenerAttached) {
|
||||||
|
container.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
this.wheelHandler as EventListener,
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
this.wheelListenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachWheelListener() {
|
||||||
|
const container = this.shadowRoot?.querySelector(
|
||||||
|
'.grid-scroll-container',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (container && this.wheelListenerAttached) {
|
||||||
|
container.removeEventListener(
|
||||||
|
'wheel',
|
||||||
|
this.wheelHandler as EventListener,
|
||||||
|
);
|
||||||
|
this.wheelListenerAttached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Grid layout
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private lastLayoutWidth = 0;
|
||||||
|
|
||||||
|
private updateGridLayout() {
|
||||||
|
if (this.cardSize === this.lastLayoutWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastLayoutWidth = this.cardSize;
|
||||||
|
this.gridLayout = this.createGridLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Dynamic size properties
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private updateSizeProperties() {
|
||||||
|
const w = this.cardSize;
|
||||||
|
|
||||||
|
if (w < 160) {
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-font',
|
||||||
|
'12px',
|
||||||
|
);
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-pad',
|
||||||
|
'4px',
|
||||||
|
);
|
||||||
|
} else if (w > 250) {
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-font',
|
||||||
|
'15px',
|
||||||
|
);
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-pad',
|
||||||
|
'8px',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-font',
|
||||||
|
'14px',
|
||||||
|
);
|
||||||
|
this.style.setProperty(
|
||||||
|
'--artist-name-pad',
|
||||||
|
'6px',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Artist card click
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private onArtistClick(artist: library.Artist) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
view: 'artist-details',
|
||||||
|
artistId: artist.ID,
|
||||||
|
artistName: artist.Name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Helpers
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private getArtistInitial(name: string): string {
|
||||||
|
if (!name) return '?';
|
||||||
|
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Rendering
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
private renderArtistCard(
|
||||||
|
entry: ArtistEntry,
|
||||||
|
) {
|
||||||
|
const { artist } = entry;
|
||||||
|
const imgSize = this.imageSize;
|
||||||
|
const placeholderFont = Math.round(
|
||||||
|
imgSize * 0.38,
|
||||||
|
);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="artist-card"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="${artist.Name}"
|
||||||
|
style="
|
||||||
|
--avatar-size: ${imgSize}px;
|
||||||
|
--placeholder-font: ${placeholderFont}px;
|
||||||
|
"
|
||||||
|
@click=${() =>
|
||||||
|
this.onArtistClick(artist)}
|
||||||
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.key === 'Enter' ||
|
||||||
|
e.key === ' '
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onArtistClick(artist);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="avatar-container">
|
||||||
|
<span class="avatar-placeholder">
|
||||||
|
${this.getArtistInitial(
|
||||||
|
artist.Name,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="artist-name"
|
||||||
|
title="${artist.Name}"
|
||||||
|
>
|
||||||
|
${artist.Name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.loading) {
|
||||||
|
return html`
|
||||||
|
<div class="loading-message">
|
||||||
|
Loading artists...
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this.gridEntries;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty-message">
|
||||||
|
${this.searchCtrl.term
|
||||||
|
? 'No artists match your search.'
|
||||||
|
: 'No artists in library.'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="grid-scroll-container">
|
||||||
|
<lit-virtualizer
|
||||||
|
.items=${entries}
|
||||||
|
.renderItem=${(
|
||||||
|
entry: ArtistEntry,
|
||||||
|
) =>
|
||||||
|
this.renderArtistCard(entry)}
|
||||||
|
.layout=${this.gridLayout}
|
||||||
|
></lit-virtualizer>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
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 '@lit-labs/virtualizer';
|
import '@lit-labs/virtualizer';
|
||||||
import type {
|
import type {
|
||||||
@@ -124,6 +129,15 @@ type SortDirection = 'asc' | 'desc';
|
|||||||
|
|
||||||
@customElement('cover-grid')
|
@customElement('cover-grid')
|
||||||
export class CoverGrid extends LitElement {
|
export class CoverGrid extends LitElement {
|
||||||
|
/**
|
||||||
|
* When set, the grid displays these albums instead of
|
||||||
|
* fetching all albums from the library store. The
|
||||||
|
* component also skips the LibraryScanComplete listener
|
||||||
|
* since the parent is responsible for reloading.
|
||||||
|
*/
|
||||||
|
@property({ type: Array, attribute: false })
|
||||||
|
externalAlbums?: library.Album[];
|
||||||
|
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
@@ -871,10 +885,16 @@ export class CoverGrid extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.restoreSortPreferences();
|
this.restoreSortPreferences();
|
||||||
this.loadAlbums();
|
this.loadAlbums();
|
||||||
this.cancelScanComplete = EventsOn(
|
|
||||||
Events.LibraryScanComplete,
|
// Skip the scan listener when driven by an
|
||||||
() => this.loadAlbums(),
|
// external album list — the parent manages
|
||||||
);
|
// reloading.
|
||||||
|
if (!this.externalAlbums) {
|
||||||
|
this.cancelScanComplete = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadAlbums(),
|
||||||
|
);
|
||||||
|
}
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
this.closeHandler,
|
this.closeHandler,
|
||||||
@@ -949,6 +969,15 @@ export class CoverGrid extends LitElement {
|
|||||||
) {
|
) {
|
||||||
super.willUpdate(changed);
|
super.willUpdate(changed);
|
||||||
|
|
||||||
|
// When the parent provides a new external album
|
||||||
|
// list, update local albums and reset selection.
|
||||||
|
if (changed.has('externalAlbums') && this.externalAlbums) {
|
||||||
|
this.albums = this.externalAlbums;
|
||||||
|
this.selectedAlbums = new Set();
|
||||||
|
this.lastSelectedAlbumIndex = null;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
// When switching albums, expandedTracks
|
// When switching albums, expandedTracks
|
||||||
// briefly goes to []. Exit split mode so
|
// briefly goes to []. Exit split mode so
|
||||||
// the single virtualizer takes over while
|
// the single virtualizer takes over while
|
||||||
@@ -1468,10 +1497,13 @@ export class CoverGrid extends LitElement {
|
|||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const albums =
|
// When driven by an external album list,
|
||||||
await this.libraryCtrl.getAlbums();
|
// skip the backend fetch entirely.
|
||||||
|
const albums = this.externalAlbums
|
||||||
|
?? (await this.libraryCtrl.getAlbums())
|
||||||
|
?? [];
|
||||||
|
|
||||||
this.albums = albums ?? [];
|
this.albums = albums;
|
||||||
this.selectedAlbums = new Set();
|
this.selectedAlbums = new Set();
|
||||||
this.lastSelectedAlbumIndex = null;
|
this.lastSelectedAlbumIndex = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ export class LibraryController implements ReactiveController {
|
|||||||
return libraryStore.getAlbums();
|
return libraryStore.getAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtists(): Promise<library.Artist[]> {
|
||||||
|
return libraryStore.getArtists();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumsByArtist(
|
||||||
|
artistID: number,
|
||||||
|
): Promise<library.Album[]> {
|
||||||
|
return libraryStore.getAlbumsByArtist(artistID);
|
||||||
|
}
|
||||||
|
|
||||||
get cachedTracks(): library.Track[] | null {
|
get cachedTracks(): library.Track[] | null {
|
||||||
return libraryStore.getCachedTracks();
|
return libraryStore.getCachedTracks();
|
||||||
}
|
}
|
||||||
@@ -59,6 +69,10 @@ export class LibraryController implements ReactiveController {
|
|||||||
return libraryStore.getCachedAlbums();
|
return libraryStore.getCachedAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get cachedArtists(): library.Artist[] | null {
|
||||||
|
return libraryStore.getCachedArtists();
|
||||||
|
}
|
||||||
|
|
||||||
get tracksLoading(): boolean {
|
get tracksLoading(): boolean {
|
||||||
return libraryStore.isTracksLoading();
|
return libraryStore.isTracksLoading();
|
||||||
}
|
}
|
||||||
@@ -67,6 +81,10 @@ export class LibraryController implements ReactiveController {
|
|||||||
return libraryStore.isAlbumsLoading();
|
return libraryStore.isAlbumsLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get artistsLoading(): boolean {
|
||||||
|
return libraryStore.isArtistsLoading();
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// SCROLL POSITION
|
// SCROLL POSITION
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { GetAllTracks, GetAllAlbums } from '@go/library/Library';
|
import {
|
||||||
|
GetAllTracks,
|
||||||
|
GetAllAlbums,
|
||||||
|
GetAllArtists,
|
||||||
|
GetAlbumsByArtist,
|
||||||
|
} from '@go/library/Library';
|
||||||
import type { library } from '@go/models';
|
import type { library } from '@go/models';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
|
|
||||||
@@ -22,9 +27,11 @@ const COVER_SIZE_KEY = 'cover-grid-size';
|
|||||||
class LibraryStore {
|
class LibraryStore {
|
||||||
private tracks: library.Track[] | null = null;
|
private tracks: library.Track[] | null = null;
|
||||||
private albums: library.Album[] | null = null;
|
private albums: library.Album[] | null = null;
|
||||||
|
private artists: library.Artist[] | null = null;
|
||||||
|
|
||||||
private tracksLoading = false;
|
private tracksLoading = false;
|
||||||
private albumsLoading = false;
|
private albumsLoading = false;
|
||||||
|
private artistsLoading = false;
|
||||||
|
|
||||||
private coverSizeValue: number = COVER_SIZE_DEFAULT;
|
private coverSizeValue: number = COVER_SIZE_DEFAULT;
|
||||||
|
|
||||||
@@ -94,6 +101,35 @@ class LibraryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtists(): Promise<library.Artist[]> {
|
||||||
|
if (this.artists !== null) {
|
||||||
|
return this.artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.artistsLoading) {
|
||||||
|
return this.waitForArtists();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.artistsLoading = true;
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artists = await GetAllArtists();
|
||||||
|
this.artists = artists;
|
||||||
|
|
||||||
|
return artists;
|
||||||
|
} finally {
|
||||||
|
this.artistsLoading = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumsByArtist(
|
||||||
|
artistID: number,
|
||||||
|
): Promise<library.Album[]> {
|
||||||
|
return GetAlbumsByArtist(artistID);
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// STATE ACCESSORS
|
// STATE ACCESSORS
|
||||||
// Synchronous access for controllers that need current cached values.
|
// Synchronous access for controllers that need current cached values.
|
||||||
@@ -115,6 +151,14 @@ class LibraryStore {
|
|||||||
return this.albumsLoading;
|
return this.albumsLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCachedArtists(): library.Artist[] | null {
|
||||||
|
return this.artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
isArtistsLoading(): boolean {
|
||||||
|
return this.artistsLoading;
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// SCROLL POSITION
|
// SCROLL POSITION
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -184,6 +228,7 @@ class LibraryStore {
|
|||||||
private invalidate(): void {
|
private invalidate(): void {
|
||||||
this.tracks = null;
|
this.tracks = null;
|
||||||
this.albums = null;
|
this.albums = null;
|
||||||
|
this.artists = null;
|
||||||
this.scrollPositions = { tracks: 0, albums: 0 };
|
this.scrollPositions = { tracks: 0, albums: 0 };
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
@@ -228,6 +273,17 @@ class LibraryStore {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private waitForArtists(): Promise<library.Artist[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const unsub = this.subscribe(() => {
|
||||||
|
if (!this.artistsLoading && this.artists !== null) {
|
||||||
|
unsub();
|
||||||
|
resolve(this.artists);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance.
|
// Singleton instance.
|
||||||
|
|||||||
@@ -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']);
|
const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists']);
|
||||||
|
|
||||||
type Subscriber = () => void;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
|||||||
+4
@@ -7,8 +7,12 @@ export function FullRescan():Promise<library.ScanMetrics>;
|
|||||||
|
|
||||||
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
|
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
|
||||||
|
|
||||||
|
export function GetAlbumsByArtist(arg1:number):Promise<Array<library.Album>>;
|
||||||
|
|
||||||
export function GetAllAlbums():Promise<Array<library.Album>>;
|
export function GetAllAlbums():Promise<Array<library.Album>>;
|
||||||
|
|
||||||
|
export function GetAllArtists():Promise<Array<library.Artist>>;
|
||||||
|
|
||||||
export function GetAllTracks():Promise<Array<library.Track>>;
|
export function GetAllTracks():Promise<Array<library.Track>>;
|
||||||
|
|
||||||
export function Scan():Promise<library.ScanMetrics>;
|
export function Scan():Promise<library.ScanMetrics>;
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ export function GetAlbumTracks(arg1) {
|
|||||||
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
|
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAlbumsByArtist(arg1) {
|
||||||
|
return window['go']['library']['Library']['GetAlbumsByArtist'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAllAlbums() {
|
export function GetAllAlbums() {
|
||||||
return window['go']['library']['Library']['GetAllAlbums']();
|
return window['go']['library']['Library']['GetAllAlbums']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAllArtists() {
|
||||||
|
return window['go']['library']['Library']['GetAllArtists']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAllTracks() {
|
export function GetAllTracks() {
|
||||||
return window['go']['library']['Library']['GetAllTracks']();
|
return window['go']['library']['Library']['GetAllTracks']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export namespace library {
|
|||||||
this.Year = source["Year"];
|
this.Year = source["Year"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Artist {
|
||||||
|
ID: number;
|
||||||
|
Name: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Artist(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ID = source["ID"];
|
||||||
|
this.Name = source["Name"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScanMetrics {
|
export class ScanMetrics {
|
||||||
total: number;
|
total: number;
|
||||||
loadExisting: number;
|
loadExisting: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user