From 90b7661483396bf4bbbde44ff9baa40f4cf230ab Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 23 Feb 2026 10:38:27 -0500 Subject: [PATCH] added track-details page --- frontend/index.ts | 1 + .../src/components/cover-grid/cover-grid.ts | 77 ++- .../components/playlist-view/playlist-view.ts | 90 ++- .../src/components/queue-panel/queue-panel.ts | 84 ++- .../components/track-details/track-details.ts | 633 ++++++++++++++++++ .../src/components/track-list/track-list.ts | 69 +- frontend/src/store/theme-store.ts | 36 +- 7 files changed, 973 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/track-details/track-details.ts diff --git a/frontend/index.ts b/frontend/index.ts index f746d3b..175d50a 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -10,6 +10,7 @@ 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/track-details/track-details.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/components/icon/icon.js'; diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index a694e03..906b234 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -23,6 +23,9 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; +import '@components/track-details/track-details.js'; +import type { TrackDetails } from '@components/track-details/track-details.js'; +import type { CoverArtUrls } from '@components/track-details/track-details.js'; import './album-dropdown.js'; import type { TrackClickDetail, @@ -696,6 +699,9 @@ export class CoverGrid extends LitElement { @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + @query('track-details') + private trackDetailsDialog!: TrackDetails; + @query('#grid-single') private virtualizerSingle!: LitVirtualizer; @@ -3066,11 +3072,49 @@ export class CoverGrid extends LitElement { case 'play-next': queueStore.playTracksNext(filePaths); break; + case 'track-details': + this.openTrackDetails(filePaths[0]!); + break; } this.closeContextMenu(true); } + private openTrackDetails(filePath: string) { + const track = this.expandedTracks.find( + (t) => t.FilePath === filePath, + ); + + if (!track) return; + + const coverArt = + this.resolveTrackCoverArt(track.Album); + + this.trackDetailsDialog?.show( + track, + coverArt ?? undefined, + ); + } + + private resolveTrackCoverArt( + albumName: string, + ): CoverArtUrls | null { + if (!albumName) return null; + + const album = this.albums.find( + (a) => a.Name === albumName, + ); + + if (!album || !album.CoverArtPath) return null; + + return { + coverArtPath: album.CoverArtPath, + coverArtSmall: album.CoverArtSmall, + coverArtMedium: album.CoverArtMedium, + coverArtLarge: album.CoverArtLarge, + }; + } + private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; @@ -3548,12 +3592,33 @@ export class CoverGrid extends LitElement { slot="icon" name="plus" > - Add to Playlist - + Add to Playlist + + ${this.contextMenuTarget + .kind === + 'track' && + this.selectedTracks + .size === 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + > + + Track + Details + + ` + : nothing} ` : nothing} @@ -3577,6 +3642,8 @@ export class CoverGrid extends LitElement { ` : nothing} + + `; } } diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 66b29a5..62f9c7f 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -40,6 +40,10 @@ import { createTrackCardDragImage, removeDragImage, } from '@utils/drag-image'; +import { libraryStore } from '@store/library-store'; +import '@components/track-details/track-details.js'; +import type { TrackDetails } from '@components/track-details/track-details.js'; +import type { CoverArtUrls } from '@components/track-details/track-details.js'; const SCROLL_DEBOUNCE_MS = 100; @@ -191,6 +195,9 @@ export class PlaylistView @query('#playlist-context-menu') private playlistContextMenuPopup!: HTMLElement; + @query('track-details') + private trackDetailsDialog!: TrackDetails; + private closeContextMenuHandler = () => { this.closeContextMenu(); this.closePlaylistContextMenu(); @@ -1066,7 +1073,9 @@ export class PlaylistView queueStore.setQueue(filePaths, 0); break; case 'add-to-queue': - queueStore.addTracksToQueue(filePaths); + queueStore.addTracksToQueue( + filePaths, + ); break; case 'play-next': queueStore.playTracksNext(filePaths); @@ -1074,11 +1083,60 @@ export class PlaylistView case 'remove': void this.removeSelectedTracks(); break; + case 'track-details': + this.openTrackDetails(filePaths[0]!); + break; } this.closeContextMenu(true); } + private openTrackDetails(filePath: string) { + const tracks = + libraryStore.getCachedTracks(); + const track = tracks?.find( + (t) => t.FilePath === filePath, + ); + + if (!track) return; + + const coverArt = + this.resolvePlaylistCoverArt( + track.Album, + ); + + this.trackDetailsDialog?.show( + track, + coverArt ?? undefined, + ); + } + + private resolvePlaylistCoverArt( + albumName: string, + ): CoverArtUrls | null { + if (!albumName) return null; + + const albums = + libraryStore.getCachedAlbums(); + + if (!albums) return null; + + const album = albums.find( + (a) => a.Name === albumName, + ); + + if (!album || !album.CoverArtPath) { + return null; + } + + return { + coverArtPath: album.CoverArtPath, + coverArtSmall: album.CoverArtSmall, + coverArtMedium: album.CoverArtMedium, + coverArtLarge: album.CoverArtLarge, + }; + } + private async removeSelectedTracks() { if (this.activePlaylistIndex < 0) return; @@ -1840,12 +1898,30 @@ export class PlaylistView name="plus" > Add to Playlist - - ▶ - + + ▶ + + ${this.selection + .selectionCount === 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + > + + Track + Details + + ` + : nothing} ` : nothing} @@ -1913,6 +1989,8 @@ export class PlaylistView ` : nothing} + + `; } diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index d8900eb..b4b420e 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -29,6 +29,10 @@ import { createTrackCardDragImage, removeDragImage, } from '@utils/drag-image'; +import { libraryStore } from '@store/library-store'; +import '@components/track-details/track-details.js'; +import type { TrackDetails } from '@components/track-details/track-details.js'; +import type { CoverArtUrls } from '@components/track-details/track-details.js'; const MIN_WIDTH = 200; const MAX_WIDTH = 500; @@ -80,6 +84,9 @@ export class QueuePanel @query('lit-virtualizer') private virtualizer!: LitVirtualizer; + @query('track-details') + private trackDetailsDialog!: TrackDetails; + private closePickerHandler = (e: MouseEvent) => { const path = e.composedPath(); const popup = this.addToPlaylistPopup; @@ -686,13 +693,68 @@ export class QueuePanel this.queue.playAtIndex(indices[0]!); break; case 'remove': - this.queue.removeTracksFromQueue(indices); + this.queue.removeTracksFromQueue( + indices, + ); + break; + case 'track-details': + this.openTrackDetails(indices[0]!); break; } this.closeContextMenu(true); } + private openTrackDetails(index: number) { + const queueTrack = + this.queue.tracks[index]; + + if (!queueTrack) return; + + const tracks = + libraryStore.getCachedTracks(); + const track = tracks?.find( + (t) => + t.FilePath === queueTrack.filePath, + ); + + if (!track) return; + + const coverArt = + this.resolveQueueCoverArt(track.Album); + + this.trackDetailsDialog?.show( + track, + coverArt ?? undefined, + ); + } + + private resolveQueueCoverArt( + albumName: string, + ): CoverArtUrls | null { + if (!albumName) return null; + + const albums = + libraryStore.getCachedAlbums(); + + if (!albums) return null; + + const album = albums.find( + (a) => a.Name === albumName, + ); + + if (!album || !album.CoverArtPath) { + return null; + } + + return { + coverArtPath: album.CoverArtPath, + coverArtSmall: album.CoverArtSmall, + coverArtMedium: album.CoverArtMedium, + coverArtLarge: album.CoverArtLarge, + }; + } + private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; @@ -1394,6 +1456,24 @@ export class QueuePanel ▶ + ${this.selection + .selectionCount === 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + > + + Track + Details + + ` + : nothing} ` : nothing} @@ -1420,6 +1500,8 @@ export class QueuePanel ` : nothing} + + `; } } diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts new file mode 100644 index 0000000..8c2dac8 --- /dev/null +++ b/frontend/src/components/track-details/track-details.ts @@ -0,0 +1,633 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { + customElement, + state, + query, +} from 'lit/decorators.js'; +import type { library } from '@go/models'; +import { formatMilliseconds } from '@utils/time'; + +import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +/** Cover art URLs resolved from the album cache. */ +export interface CoverArtUrls { + coverArtPath: string; + coverArtSmall: string; + coverArtMedium: string; + coverArtLarge: string; +} + +/** Editable field definition. */ +interface MetadataField { + key: string; + label: string; + value: string; + editable: boolean; + type: 'text' | 'number'; +} + +/** + * Modal dialog displaying detailed metadata for a single track. + * + * Call `show(track, coverArt?)` to open and `close()` to dismiss. + * Includes an edit toggle for future tag-writing support. + */ +@customElement('track-details') +export class TrackDetails extends LitElement { + @state() private track: library.Track | null = null; + @state() private coverArt: CoverArtUrls | null = null; + @state() private editing = false; + @state() private editValues: Record = {}; + + @query('wa-dialog') + private dialog!: HTMLElement & { + show: () => void; + hide: () => void; + }; + + // ================================================================= + // PUBLIC API + // ================================================================= + + /** Open the dialog for the given track. */ + show( + track: library.Track, + coverArt?: CoverArtUrls, + ): void { + this.track = track; + this.coverArt = coverArt ?? null; + this.editing = false; + this.editValues = {}; + + this.updateComplete.then(() => { + this.dialog?.show(); + }); + } + + /** Close the dialog. */ + close(): void { + this.dialog?.hide(); + this.editing = false; + this.editValues = {}; + } + + // ================================================================= + // STYLES + // ================================================================= + + static override styles = css` + wa-dialog { + --width: 640px; + } + + wa-dialog::part(dialog) { + background: var(--yj-bg-surface, #212529); + color: var(--yj-text-primary, #fff); + border: 1px solid var(--yj-border, #444); + border-radius: 8px; + } + + wa-dialog::part(title) { + font-size: 16px; + font-weight: 600; + color: var(--yj-text-primary, #fff); + padding: 16px 20px 8px; + } + + wa-dialog::part(header-actions) { + padding: 16px 20px 8px; + } + + wa-dialog::part(close-button__base) { + color: var(--yj-text-tertiary, #888); + } + + wa-dialog::part(body) { + padding: 0 20px 20px; + } + + .top-section { + display: flex; + gap: 20px; + margin-bottom: 20px; + } + + .cover-art { + width: 200px; + height: 200px; + flex-shrink: 0; + border-radius: 6px; + overflow: hidden; + } + + .cover-art img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .cover-placeholder { + width: 100%; + height: 100%; + background-color: var( + --yj-bg-elevated, + #343a40 + ); + display: flex; + align-items: center; + justify-content: center; + } + + .cover-placeholder wa-icon { + color: var(--yj-text-tertiary, #888); + font-size: 64px; + } + + .main-meta { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + flex: 1; + justify-content: center; + } + + .main-meta .title { + font-size: 22px; + font-weight: 600; + color: var(--yj-text-primary, #fff); + word-break: break-word; + } + + .main-meta .artist { + font-size: 15px; + color: var(--yj-text-secondary, #b3b3b3); + } + + .main-meta .album { + font-size: 14px; + color: var(--yj-text-tertiary, #888); + } + + .main-meta .duration { + font-size: 13px; + color: var(--yj-text-tertiary, #888); + font-variant-numeric: tabular-nums; + } + + .divider { + height: 1px; + background: var(--yj-border-subtle, #333); + margin-bottom: 16px; + } + + .metadata-grid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 8px 12px; + align-items: baseline; + } + + .meta-label { + font-size: 12px; + font-weight: 500; + color: var(--yj-text-tertiary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .meta-value { + font-size: 13px; + color: var(--yj-text-secondary, #b3b3b3); + word-break: break-word; + } + + .meta-value.empty { + color: var(--yj-text-tertiary, #888); + font-style: italic; + } + + /* Edit mode inputs */ + .meta-input { + width: 100%; + box-sizing: border-box; + background: var(--yj-bg-elevated, #343a40); + border: 1px solid + var(--yj-border-subtle, #333); + border-radius: 4px; + color: var(--yj-text-primary, #fff); + font-size: 13px; + padding: 4px 8px; + font-family: inherit; + } + + .meta-input:focus { + outline: none; + border-color: var(--yj-accent, #ffd43b); + } + + .main-input { + background: var(--yj-bg-elevated, #343a40); + border: 1px solid + var(--yj-border-subtle, #333); + border-radius: 4px; + color: var(--yj-text-primary, #fff); + font-family: inherit; + padding: 4px 8px; + width: 100%; + box-sizing: border-box; + } + + .main-input:focus { + outline: none; + border-color: var(--yj-accent, #ffd43b); + } + + .main-input.title-input { + font-size: 20px; + font-weight: 600; + } + + .main-input.artist-input { + font-size: 14px; + } + + .main-input.album-input { + font-size: 13px; + } + + /* Action bar */ + .action-bar { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } + + .btn { + padding: 6px 16px; + border-radius: 4px; + border: 1px solid var(--yj-border, #444); + background: var( + --yj-bg-elevated, + #343a40 + ); + color: var(--yj-text-primary, #fff); + font-size: 13px; + cursor: pointer; + font-family: inherit; + transition: background-color 0.15s ease; + } + + .btn:hover { + background: var(--yj-bg-overlay, #495057); + } + + .btn-primary { + background: var(--yj-accent, #ffd43b); + color: #000; + border-color: var(--yj-accent, #ffd43b); + } + + .btn-primary:hover { + background: var( + --yj-accent-hover, + #ffe066 + ); + border-color: var( + --yj-accent-hover, + #ffe066 + ); + } + `; + + // ================================================================= + // RENDER + // ================================================================= + + override render() { + return html` + + ${this.track + ? this.renderContent() + : nothing} + + `; + } + + private renderContent() { + const t = this.track!; + + return html` +
+ ${this.renderCoverArt()} +
+ ${this.renderMainFields(t)} +
+
+
+ +
+ ${this.renderActions()} +
+ `; + } + + private renderCoverArt() { + const src = + this.coverArt?.coverArtLarge ?? + this.coverArt?.coverArtMedium ?? + this.coverArt?.coverArtPath; + + if (!src) { + return html` +
+
+ +
+
+ `; + } + + return html` +
+ Album cover +
+ `; + } + + private handleImageError = (e: Event) => { + const img = e.target as HTMLImageElement; + const fallback = this.coverArt?.coverArtPath; + + if (fallback && img.src !== fallback) { + img.src = fallback; + + return; + } + + const container = img.parentElement; + + if (container) { + container.innerHTML = + '
' + + '' + + '
'; + } + }; + + private renderMainFields(t: library.Track) { + if (this.editing) { + return html` + + this.onEditInput( + 'title', + e, + )} + placeholder="Title" + /> + + this.onEditInput( + 'artist', + e, + )} + placeholder="Artist" + /> + + this.onEditInput( + 'album', + e, + )} + placeholder="Album" + /> + + ${formatMilliseconds(t.TrackLength)} + + `; + } + + return html` + + ${t.TrackName || this.fileNameFromPath(t.FilePath)} + + + ${t.ArtistName || 'Unknown Artist'} + + ${t.Album + ? html`${t.Album}` + : nothing} + + ${formatMilliseconds(t.TrackLength)} + + `; + } + + private renderDetailFields(t: library.Track) { + const fields: MetadataField[] = [ + { + key: 'genre', + label: 'Genre', + value: (t.Genre ?? []).join(', '), + editable: true, + type: 'text', + }, + { + key: 'year', + label: 'Year', + value: t.Year ? String(t.Year) : '', + editable: true, + type: 'number', + }, + { + key: 'composer', + label: 'Composer', + value: t.Composer ?? '', + editable: true, + type: 'text', + }, + { + key: 'trackNumber', + label: 'Track #', + value: t.TrackNumber + ? String(t.TrackNumber) + : '', + editable: true, + type: 'number', + }, + { + key: 'discNumber', + label: 'Disc #', + value: t.DiscNumber + ? String(t.DiscNumber) + : '', + editable: true, + type: 'number', + }, + { + key: 'fileType', + label: 'File Type', + value: t.FileType ?? '', + editable: false, + type: 'text', + }, + { + key: 'filePath', + label: 'File Path', + value: t.FilePath ?? '', + editable: false, + type: 'text', + }, + ]; + + return fields.map((f) => this.renderField(f)); + } + + private renderField(f: MetadataField) { + const display = + this.getEditValue(f.key, f.value) || f.value; + + return html` + ${f.label} + ${this.editing && f.editable + ? html` + + this.onEditInput( + f.key, + e, + )} + /> + ` + : html` + + ${display || 'None'} + + `} + `; + } + + private renderActions() { + if (this.editing) { + return html` + + + `; + } + + return html` + + `; + } + + // ================================================================= + // EDIT LOGIC + // ================================================================= + + private startEdit = () => { + this.editing = true; + this.editValues = {}; + }; + + private cancelEdit = () => { + this.editing = false; + this.editValues = {}; + }; + + private saveEdit = () => { + // TODO: implement tag writing when backend support is added. + // For now, just exit edit mode. + this.editing = false; + this.editValues = {}; + }; + + private getEditValue( + key: string, + fallback: string, + ): string { + return key in this.editValues + ? this.editValues[key]! + : fallback; + } + + private onEditInput(key: string, e: Event) { + const input = e.target as HTMLInputElement; + + this.editValues = { + ...this.editValues, + [key]: input.value, + }; + } + + // ================================================================= + // HELPERS + // ================================================================= + + private fileNameFromPath(filePath: string): string { + const parts = filePath.split(/[\\/]/); + const filename = + parts[parts.length - 1] ?? filePath; + + return filename.replace(/\.[^.]+$/, ''); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'track-details': TrackDetails; + } +} diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index b07d5e0..f1b779d 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -35,6 +35,9 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; +import '@components/track-details/track-details.js'; +import type { TrackDetails } from '@components/track-details/track-details.js'; +import type { CoverArtUrls } from '@components/track-details/track-details.js'; const COLUMN_STORAGE_KEY = 'track-list-column-widths'; const SORT_FIELD_KEY = 'track-list-sort-field'; @@ -93,6 +96,9 @@ export class TrackList extends LitElement implements SelectionHost { @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + @query('track-details') + private trackDetailsDialog!: TrackDetails; + @query('lit-virtualizer') private virtualizer!: LitVirtualizer; @@ -1163,7 +1169,8 @@ export class TrackList extends LitElement implements SelectionHost { }; private onContextMenuAction(action: string) { - const filePaths = this.selection.getSelectedKeysOrdered(); + const filePaths = + this.selection.getSelectedKeysOrdered(); if (filePaths.length === 0) return; @@ -1177,11 +1184,53 @@ export class TrackList extends LitElement implements SelectionHost { case 'play-next': queueStore.playTracksNext(filePaths); break; + case 'track-details': + this.openTrackDetails(filePaths[0]!); + break; } this.closeContextMenu(true); } + private openTrackDetails(filePath: string) { + const track = this.tracks.find( + (t) => t.FilePath === filePath, + ); + + if (!track) return; + + const coverArt = + this.resolveCoverArt(track.Album); + + this.trackDetailsDialog?.show( + track, + coverArt ?? undefined, + ); + } + + private resolveCoverArt( + albumName: string, + ): CoverArtUrls | null { + if (!albumName) return null; + + const albums = this.libraryCtrl.cachedAlbums; + + if (!albums) return null; + + const album = albums.find( + (a) => a.Name === albumName, + ); + + if (!album || !album.CoverArtPath) return null; + + return { + coverArtPath: album.CoverArtPath, + coverArtSmall: album.CoverArtSmall, + coverArtMedium: album.CoverArtMedium, + coverArtLarge: album.CoverArtLarge, + }; + } + private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; @@ -1651,6 +1700,22 @@ export class TrackList extends LitElement implements SelectionHost { Add to Playlist + ${this.selection.selectionCount === 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + > + + Track Details + + ` + : nothing} ` : nothing} @@ -1673,6 +1738,8 @@ export class TrackList extends LitElement implements SelectionHost { ` : nothing} + + `; } } diff --git a/frontend/src/store/theme-store.ts b/frontend/src/store/theme-store.ts index ac2381d..2a06982 100644 --- a/frontend/src/store/theme-store.ts +++ b/frontend/src/store/theme-store.ts @@ -280,10 +280,38 @@ class ThemeStore { // Set the document color-scheme so native form controls // (select dropdowns, scrollbars, etc.) match the theme. - root.style.colorScheme = - this.state.backgroundShade === 'light' - ? 'light' - : 'dark'; + const isDark = + this.state.backgroundShade !== 'light'; + + root.style.colorScheme = isDark + ? 'dark' + : 'light'; + + // Bridge to WebAwesome's theme system so wa-dialog, + // wa-drawer, and other WA components inherit the + // correct surface colours instead of defaulting to + // white (light mode). + if (isDark) { + root.classList.add('wa-dark'); + } else { + root.classList.remove('wa-dark'); + } + + const palette = + SHADE_PALETTES[this.state.backgroundShade]; + + root.style.setProperty( + '--wa-color-surface-raised', + palette.bgSurface, + ); + root.style.setProperty( + '--wa-color-surface-default', + palette.bgBase, + ); + root.style.setProperty( + '--wa-color-surface-lowered', + palette.bgElevated, + ); } }