fix: virtual list and cover grid (#63)

* very basic slow buggy queue

* cover grid can now add albums to queue

* added virtualized lists to track list and cover grid components
This commit is contained in:
2026-02-14 14:07:25 -06:00
committed by GitHub
parent e32b217912
commit 7579a768be
11 changed files with 508 additions and 548 deletions
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.29.0
// source: playlists.sql // source: playlists.sql
package sqlcgen package sqlcgen
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.29.0
// source: queue.sql // source: queue.sql
package sqlcgen package sqlcgen
+29 -42
View File
@@ -38,8 +38,8 @@ type TrackLoader interface {
CurrentPositionSeconds() (int, error) CurrentPositionSeconds() (int, error)
} }
// Track represents a track in the queue with its metadata. // QueueTrack represents a track in the queue with its metadata.
type Track struct { type QueueTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AudioFileID int64 `json:"audioFileId"` AudioFileID int64 `json:"audioFileId"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
@@ -48,13 +48,13 @@ type Track struct {
Artist string `json:"artist"` Artist string `json:"artist"`
} }
// State is the full state emitted to the frontend. // QueueState is the full state emitted to the frontend.
type State struct { type QueueState struct {
Tracks []Track `json:"tracks"` Tracks []QueueTrack `json:"tracks"`
CurrentIndex int `json:"currentIndex"` CurrentIndex int `json:"currentIndex"`
ShuffleMode bool `json:"shuffleMode"` ShuffleMode bool `json:"shuffleMode"`
RepeatMode RepeatMode `json:"repeatMode"` RepeatMode RepeatMode `json:"repeatMode"`
SourcePlaylistID int64 `json:"sourcePlaylistId"` SourcePlaylistID int64 `json:"sourcePlaylistId"`
} }
// Queue manages an ordered list of tracks for playback. // Queue manages an ordered list of tracks for playback.
@@ -65,7 +65,7 @@ type Queue struct {
player TrackLoader player TrackLoader
mu sync.Mutex mu sync.Mutex
tracks []Track tracks []QueueTrack
currentIndex int currentIndex int
shuffleMode bool shuffleMode bool
repeatMode RepeatMode repeatMode RepeatMode
@@ -331,7 +331,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
defer q.mu.Unlock() defer q.mu.Unlock()
// Look up audio file IDs and metadata for all paths. // Look up audio file IDs and metadata for all paths.
tracks := make([]Track, 0, len(filePaths)) tracks := make([]QueueTrack, 0, len(filePaths))
for i, fp := range filePaths { for i, fp := range filePaths {
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp) af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
@@ -341,7 +341,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
continue continue
} }
track := Track{ track := QueueTrack{
AudioFileID: af.ID, AudioFileID: af.ID,
FilePath: fp, FilePath: fp,
Position: int64(i), Position: int64(i),
@@ -395,7 +395,7 @@ func (q *Queue) AddTrack(filePath string) {
wasEmpty := len(q.tracks) == 0 wasEmpty := len(q.tracks) == 0
track := Track{ track := QueueTrack{
AudioFileID: af.ID, AudioFileID: af.ID,
FilePath: filePath, FilePath: filePath,
Position: int64(len(q.tracks)), Position: int64(len(q.tracks)),
@@ -449,7 +449,7 @@ func (q *Queue) AddTracks(filePaths []string) {
continue continue
} }
track := Track{ track := QueueTrack{
AudioFileID: af.ID, AudioFileID: af.ID,
FilePath: fp, FilePath: fp,
Position: int64(len(q.tracks)), Position: int64(len(q.tracks)),
@@ -490,8 +490,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
} }
wasEmpty := len(q.tracks) == 0 wasEmpty := len(q.tracks) == 0
var newTracks []QueueTrack
var newTracks []Track
for _, fp := range filePaths { for _, fp := range filePaths {
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp) af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
@@ -501,7 +500,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
continue continue
} }
track := Track{ track := QueueTrack{
AudioFileID: af.ID, AudioFileID: af.ID,
FilePath: fp, FilePath: fp,
} }
@@ -520,7 +519,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
} }
// Insert the block into the slice at insertPos. // Insert the block into the slice at insertPos.
tail := make([]Track, len(q.tracks[insertPos:])) tail := make([]QueueTrack, len(q.tracks[insertPos:]))
copy(tail, q.tracks[insertPos:]) copy(tail, q.tracks[insertPos:])
q.tracks = append(q.tracks[:insertPos], newTracks...) q.tracks = append(q.tracks[:insertPos], newTracks...)
q.tracks = append(q.tracks, tail...) q.tracks = append(q.tracks, tail...)
@@ -559,7 +558,7 @@ func (q *Queue) InsertNext(filePath string) {
insertPos = len(q.tracks) insertPos = len(q.tracks)
} }
track := Track{ track := QueueTrack{
AudioFileID: af.ID, AudioFileID: af.ID,
FilePath: filePath, FilePath: filePath,
Position: int64(insertPos), Position: int64(insertPos),
@@ -573,7 +572,7 @@ func (q *Queue) InsertNext(filePath string) {
} }
// Insert into slice. // Insert into slice.
q.tracks = append(q.tracks, Track{}) q.tracks = append(q.tracks, QueueTrack{})
copy(q.tracks[insertPos+1:], q.tracks[insertPos:]) copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
q.tracks[insertPos] = track q.tracks[insertPos] = track
@@ -711,14 +710,14 @@ func (q *Queue) CycleRepeat() {
} }
// GetState returns the current queue state for the frontend. // GetState returns the current queue state for the frontend.
func (q *Queue) GetState() State { func (q *Queue) GetState() QueueState {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
tracks := make([]Track, len(q.tracks)) tracks := make([]QueueTrack, len(q.tracks))
copy(tracks, q.tracks) copy(tracks, q.tracks)
return State{ return QueueState{
Tracks: tracks, Tracks: tracks,
CurrentIndex: q.currentIndex, CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode, ShuffleMode: q.shuffleMode,
@@ -791,10 +790,10 @@ func (q *Queue) RestoreState() {
return return
} }
q.tracks = make([]Track, 0, len(rows)) q.tracks = make([]QueueTrack, 0, len(rows))
for _, row := range rows { for _, row := range rows {
q.tracks = append(q.tracks, Track{ q.tracks = append(q.tracks, QueueTrack{
ID: row.ID, ID: row.ID,
AudioFileID: row.AudioFileID, AudioFileID: row.AudioFileID,
FilePath: row.FilePath, FilePath: row.FilePath,
@@ -955,25 +954,13 @@ func (q *Queue) playCurrentTrack() {
} }
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) { if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
q.logger.Warn( q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
"Current index out of range",
"index",
q.currentIndex,
"trackCount",
len(q.tracks),
)
return return
} }
track := q.tracks[q.currentIndex] track := q.tracks[q.currentIndex]
q.logger.Info( q.logger.Info("Playing track from queue", "filePath", track.FilePath, "position", q.currentIndex)
"Playing track from queue",
"filePath",
track.FilePath,
"position",
q.currentIndex,
)
err := q.player.LoadFile(track.FilePath) err := q.player.LoadFile(track.FilePath)
if err != nil { if err != nil {
@@ -993,8 +980,8 @@ func (q *Queue) playCurrentTrack() {
// onQueueExhausted is called when there are no more tracks to play. // onQueueExhausted is called when there are no more tracks to play.
// This is the extension point for a future fallback playlist feature. // This is the extension point for a future fallback playlist feature.
func (q *Queue) onQueueExhausted() { func (q *Queue) onQueueExhausted() {
// Future: load fallback playlist here.
q.logger.Info("Queue exhausted, stopping playback") q.logger.Info("Queue exhausted, stopping playback")
// Future: load fallback playlist here.
} }
// reindexPositions updates the Position field of all tracks to match slice index. // reindexPositions updates the Position field of all tracks to match slice index.
@@ -1060,7 +1047,7 @@ func (q *Queue) emitQueueChanged() {
return return
} }
state := State{ state := QueueState{
Tracks: q.tracks, Tracks: q.tracks,
CurrentIndex: q.currentIndex, CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode, ShuffleMode: q.shuffleMode,
@@ -1070,7 +1057,7 @@ func (q *Queue) emitQueueChanged() {
// Ensure tracks is never nil in JSON. // Ensure tracks is never nil in JSON.
if state.Tracks == nil { if state.Tracks == nil {
state.Tracks = []Track{} state.Tracks = []QueueTrack{}
} }
runtime.EventsEmit(q.ctx, events.QueueChanged, state) runtime.EventsEmit(q.ctx, events.QueueChanged, state)
+5 -1
View File
@@ -112,5 +112,9 @@ body div.sidebar {
grid-area: main-panel; grid-area: main-panel;
padding: 0.25em; padding: 0.25em;
background-color: #212529; background-color: #212529;
overflow: auto; overflow: hidden;
}
.main-panel > * {
height: 100%;
} }
-13
View File
@@ -10,10 +10,6 @@ import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
setBasePath('/dist/webawesome'); setBasePath('/dist/webawesome');
// Scroll state management per view
const scrollPositions = new Map<string, number>();
let currentView = 'tracks';
// Navigation event listener for view switching // Navigation event listener for view switching
document.addEventListener('navigate', (e: Event) => { document.addEventListener('navigate', (e: Event) => {
const { view } = (e as CustomEvent).detail; const { view } = (e as CustomEvent).detail;
@@ -21,9 +17,6 @@ document.addEventListener('navigate', (e: Event) => {
if (!mainContent) return; if (!mainContent) return;
// Save scroll position for current view before switching
scrollPositions.set(currentView, mainContent.scrollTop);
switch (view) { switch (view) {
case 'albums': case 'albums':
mainContent.innerHTML = '<cover-grid></cover-grid>'; mainContent.innerHTML = '<cover-grid></cover-grid>';
@@ -36,12 +29,6 @@ document.addEventListener('navigate', (e: Event) => {
<p>Coming soon: ${view}</p> <p>Coming soon: ${view}</p>
</div>`; </div>`;
} }
// Restore scroll position for new view
mainContent.scrollTop = scrollPositions.get(view) ?? 0;
// Update current view tracker
currentView = view;
}); });
// Queue panel toggle // Queue panel toggle
+1
View File
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@awesome.me/webawesome": "^3.2.1", "@awesome.me/webawesome": "^3.2.1",
"@lit-labs/signals": "^0.2.0", "@lit-labs/signals": "^0.2.0",
"@lit-labs/virtualizer": "^2.1.1",
"htmx.org": "2.0.8", "htmx.org": "2.0.8",
"lit": "^3.2.1" "lit": "^3.2.1"
}, },
+1 -1
View File
@@ -1 +1 @@
0abc2bb78bacb130d41b3ed39483ae9f 02c7eb24a50fc8301be7488868be5860
+324 -370
View File
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@ import { EventsEmit } from '@runtime/runtime';
import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library'; import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models'; import { library } from '@go/models';
import { QueueController } from '@store/controllers/queue-controller'; import { QueueController } from '@store/controllers/queue-controller';
import '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -16,14 +18,14 @@ export class CoverGrid extends LitElement {
static override styles = css` static override styles = css`
:host { :host {
display: block; display: flex;
flex-direction: column;
overflow: hidden;
} }
.grid { lit-virtualizer {
display: grid; flex: 1;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); overflow-y: auto;
gap: 16px;
padding: 16px;
} }
.album-card { .album-card {
@@ -33,6 +35,7 @@ export class CoverGrid extends LitElement {
border-radius: 8px; border-radius: 8px;
padding: 8px; padding: 8px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
box-sizing: border-box;
} }
.album-card:hover { .album-card:hover {
@@ -260,6 +263,61 @@ export class CoverGrid extends LitElement {
} }
} }
private renderAlbumCard = (album: library.Album): unknown => {
return html`
<div
class="album-card"
tabindex="0"
role="button"
aria-label="${album.Name} by ${album.ArtistName}"
@click=${() => this.onAlbumClick(album)}
@keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)}
@contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)}
>
<div class="cover-container">
${album.CoverArtPath
? html`<img
class="cover-image"
src="${album.CoverArtPath}"
alt="${album.Name} cover"
loading="lazy"
/>`
: html`<div class="placeholder-cover">
${this.getAlbumInitial(album.Name)}
</div>`}
</div>
<div class="album-info">
<div class="album-name" title="${album.Name}">${album.Name}</div>
<div class="artist-name" title="${album.ArtistName}">
${album.ArtistName}${album.Year ? ` - ${album.Year}` : ''}
</div>
</div>
</div>
`;
};
private getAlbumInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
private onAlbumClick(album: library.Album) {
EventsEmit('AlbumSelected', album);
this.dispatchEvent(
new CustomEvent('album-selected', {
detail: album,
bubbles: true,
composed: true,
})
);
}
private onAlbumKeydown(e: KeyboardEvent, album: library.Album) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.onAlbumClick(album);
}
}
override render() { override render() {
if (this.loading) { if (this.loading) {
return html`<div class="loading">Loading albums...</div>`; return html`<div class="loading">Loading albums...</div>`;
@@ -275,9 +333,16 @@ export class CoverGrid extends LitElement {
} }
return html` return html`
<div class="grid"> <lit-virtualizer
${this.albums.map(album => this.renderAlbumCard(album))} scroller
</div> .items=${this.albums}
.renderItem=${this.renderAlbumCard}
.layout=${grid({
itemSize: { width: '176px', height: '230px' },
gap: '16px',
padding: '16px',
})}
></lit-virtualizer>
<wa-popup <wa-popup
id="context-menu" id="context-menu"
@@ -311,61 +376,6 @@ export class CoverGrid extends LitElement {
</wa-popup> </wa-popup>
`; `;
} }
private renderAlbumCard(album: library.Album) {
return html`
<div
class="album-card"
tabindex="0"
role="button"
aria-label="${album.Name} by ${album.ArtistName}"
@click=${() => this.onAlbumClick(album)}
@keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)}
@contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)}
>
<div class="cover-container">
${album.CoverArtPath
? html`<img
class="cover-image"
src="${album.CoverArtPath}"
alt="${album.Name} cover"
loading="lazy"
/>`
: html`<div class="placeholder-cover">
${this.getAlbumInitial(album.Name)}
</div>`}
</div>
<div class="album-info">
<div class="album-name" title="${album.Name}">${album.Name}</div>
<div class="artist-name" title="${album.ArtistName}">
${album.ArtistName}${album.Year ? ` - ${album.Year}` : ''}
</div>
</div>
</div>
`;
}
private getAlbumInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
private onAlbumClick(album: library.Album) {
EventsEmit('AlbumSelected', album);
this.dispatchEvent(
new CustomEvent('album-selected', {
detail: album,
bubbles: true,
composed: true,
})
);
}
private onAlbumKeydown(e: KeyboardEvent, album: library.Album) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.onAlbumClick(album);
}
}
} }
declare global { declare global {
@@ -6,6 +6,8 @@ import { customElement, state, query } from 'lit/decorators.js';
import { formatMilliseconds } from '@utils/time'; import { formatMilliseconds } from '@utils/time';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller'; import { QueueController } from '@store/controllers/queue-controller';
import '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -30,42 +32,48 @@ export class TrackList extends LitElement {
private closeHandler = () => this.closeContextMenu(); private closeHandler = () => this.closeContextMenu();
static override styles = css` static override styles = css`
table { :host {
width: 100%; display: flex;
border-collapse: collapse; flex-direction: column;
overflow: hidden;
} }
th { .header-row {
display: grid;
grid-template-columns: 1fr 1fr 80px;
padding: 8px; padding: 8px;
text-align: left;
font-weight: bold; font-weight: bold;
color: #fff; color: #fff;
}
thead tr {
border-bottom: 1px solid #666; border-bottom: 1px solid #666;
flex-shrink: 0;
} }
tbody tr { lit-virtualizer {
flex: 1;
overflow-y: auto;
}
.track-row {
display: grid;
grid-template-columns: 1fr 1fr 80px;
padding: 8px;
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
align-items: center;
width: 100%;
} }
tbody tr:hover { .track-row:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
} }
tbody tr.active { .track-row.active {
background-color: rgba(255, 212, 59, 0.1); background-color: rgba(255, 212, 59, 0.1);
} }
tbody tr.active .track-name-button { .track-row.active .track-name-button {
color: #ffd43b; color: #ffd43b;
} }
td {
padding: 8px;
}
.track-name-button { .track-name-button {
background: none; background: none;
border: none; border: none;
@@ -74,12 +82,23 @@ export class TrackList extends LitElement {
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
font: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.track-name-button:hover { .track-name-button:hover {
text-decoration: underline; text-decoration: underline;
} }
.artist-name,
.track-length {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#context-menu { #context-menu {
z-index: 200; z-index: 200;
} }
@@ -209,45 +228,45 @@ export class TrackList extends LitElement {
return currentTrack.filePath === track.FilePath; return currentTrack.filePath === track.FilePath;
} }
private renderTrackRow = (track: library.Track): unknown => {
const active = this.isActiveTrack(track);
return html`
<div
class="track-row ${active ? 'active' : ''}"
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}
>
<div>
<button
class="track-name-button"
@click=${() => this.onTrackClick(track)}
>
${track.TrackName}
</button>
</div>
<div class="artist-name">${track.ArtistName}</div>
<div class="track-length">${formatMilliseconds(track.TrackLength)}</div>
</div>
`;
};
override render() { override render() {
return html` return html`
<div> ${this.tracks.length === 0
${this.tracks.length === 0 ? html`<p>Loading tracks...</p>`
? html`<p>Loading tracks...</p>` : html`
: html` <div class="header-row">
<table> <span>Track Name</span>
<thead> <span>Artist</span>
<tr> <span>Track Length</span>
<th>Track Name</th> </div>
<th>Artist</th> <lit-virtualizer
<th>Track Length</th> scroller
</tr> .items=${this.tracks}
</thead> .renderItem=${this.renderTrackRow}
<tbody> .layout=${flow()}
${this.tracks.map( ></lit-virtualizer>
(track) => html` `}
<tr
class=${this.isActiveTrack(track) ? 'active' : ''}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
>
<td>
<button
class="track-name-button"
@click=${() => this.onTrackClick(track)}
>
${track.TrackName}
</button>
</td>
<td>${track.ArtistName}</td>
<td>${formatMilliseconds(track.TrackLength)}</td>
</tr>
`
)}
</tbody>
</table>
`}
</div>
<wa-popup <wa-popup
id="context-menu" id="context-menu"
-2
View File
@@ -7,8 +7,6 @@ export interface QueueTrack {
audioFileId: number; audioFileId: number;
filePath: string; filePath: string;
position: number; position: number;
title: string;
artist: string;
} }
export type RepeatMode = 'off' | 'all' | 'one'; export type RepeatMode = 'off' | 'all' | 'one';