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:
@@ -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,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
@@ -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
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
0abc2bb78bacb130d41b3ed39483ae9f
|
02c7eb24a50fc8301be7488868be5860
|
||||||
Generated
+324
-370
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"
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user