adjusted frontend event handling to optimize json payload size for different events, fixed incorrect behavior when currently playing track is removed from queue

This commit is contained in:
2026-02-18 00:49:29 -05:00
parent be6f4e9764
commit 026ab1c333
14 changed files with 2221 additions and 574 deletions
+16 -12
View File
@@ -31,18 +31,22 @@ const (
// Queue events.
const (
QueueChanged = "QueueChanged"
RequestNext = "RequestNext"
RequestPrevious = "RequestPrevious"
RequestSetQueue = "RequestSetQueue"
RequestAddToQueue = "RequestAddToQueue"
RequestPlayNext = "RequestPlayNext"
RequestRemoveFromQueue = "RequestRemoveFromQueue"
RequestToggleShuffle = "RequestToggleShuffle"
RequestCycleRepeat = "RequestCycleRepeat"
RequestAddTracksToQueue = "RequestAddTracksToQueue"
RequestPlayTracksNext = "RequestPlayTracksNext"
RequestPlayQueueIndex = "RequestPlayQueueIndex"
QueueChanged = "QueueChanged"
QueueIndexChanged = "QueueIndexChanged"
QueueModeChanged = "QueueModeChanged"
QueueTracksModified = "QueueTracksModified"
RequestNext = "RequestNext"
RequestPrevious = "RequestPrevious"
RequestSetQueue = "RequestSetQueue"
RequestAddToQueue = "RequestAddToQueue"
RequestPlayNext = "RequestPlayNext"
RequestRemoveFromQueue = "RequestRemoveFromQueue"
RequestToggleShuffle = "RequestToggleShuffle"
RequestCycleRepeat = "RequestCycleRepeat"
RequestAddTracksToQueue = "RequestAddTracksToQueue"
RequestPlayTracksNext = "RequestPlayTracksNext"
RequestPlayQueueIndex = "RequestPlayQueueIndex"
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
)
// Config events.
+40
View File
@@ -299,6 +299,46 @@ func (s *Service) CreatePlaylistWithTracks(
return summary, nil
}
// RemoveTracksFromPlaylist removes multiple tracks from a playlist by their
// playlist_track IDs. Each track is removed individually using the existing
// RemovePlaylistTrack query.
func (s *Service) RemoveTracksFromPlaylist(
playlistID int64,
trackIDs []int64,
) error {
if len(trackIDs) == 0 {
return nil
}
for _, id := range trackIDs {
if err := s.db.Queries.RemovePlaylistTrack(
s.db.Ctx,
id,
); err != nil {
s.logger.Error(
"Failed to remove playlist track",
"playlistId", playlistID,
"trackId", id,
"err", err,
)
return fmt.Errorf(
"failed to remove track %d from playlist: %w",
id,
err,
)
}
}
s.logger.Info(
"Tracks removed from playlist",
"playlistId", playlistID,
"count", len(trackIDs),
)
return nil
}
// addSingleTrack looks up the audio file by path and inserts it into the playlist.
func (s *Service) addSingleTrack(
playlistID int64,
+322 -43
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"log/slog"
"math/rand/v2"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -38,6 +39,10 @@ const PreviousRestartThreshold = 3
// per statement. We use a conservative limit for batching.
const maxSQLiteVars = 900
// initialBatchSize is the number of tracks resolved eagerly in the first
// phase of SetQueue so the queue panel is populated immediately.
const initialBatchSize = 50
// trackMeta holds the result of a batch metadata lookup.
type trackMeta struct {
AudioFileID int64
@@ -74,6 +79,26 @@ type State struct {
SourcePlaylistID int64 `json:"sourcePlaylistId"`
}
// IndexChanged is the payload for the QueueIndexChanged event.
type IndexChanged struct {
CurrentIndex int `json:"currentIndex"`
}
// ModeChanged is the payload for the QueueModeChanged event.
type ModeChanged struct {
ShuffleMode bool `json:"shuffleMode"`
RepeatMode RepeatMode `json:"repeatMode"`
}
// TracksModified is the payload for the QueueTracksModified event.
type TracksModified struct {
Action string `json:"action"`
Tracks []Track `json:"tracks,omitempty"`
Index int `json:"index"`
Positions []int `json:"positions,omitempty"`
CurrentIndex int `json:"currentIndex"`
}
// Queue manages an ordered list of tracks for playback.
type Queue struct {
ctx context.Context
@@ -127,7 +152,7 @@ func (q *Queue) OnPlaybackFinished() {
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
q.playCurrentTrack()
q.emitQueueChanged()
q.emitIndexChanged()
return
}
@@ -142,7 +167,7 @@ func (q *Queue) OnPlaybackFinished() {
q.currentIndex = nextIdx
q.playCurrentTrack()
q.emitQueueChanged()
q.emitIndexChanged()
}
// registerEventHandlers sets up Wails event listeners for queue commands.
@@ -236,6 +261,17 @@ func (q *Queue) registerEventHandlers() {
q.handlePlayQueueIndex(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestRemoveTracksFromQueue,
func(data ...any) {
q.logger.Info(
"Received RequestRemoveTracksFromQueue",
)
q.handleRemoveTracksFromQueue(data...)
},
)
}
// handleSetQueue processes the RequestSetQueue event payload.
@@ -337,6 +373,38 @@ func (q *Queue) handleRemoveFromQueue(data ...any) {
q.RemoveTrack(int(position))
}
// handleRemoveTracksFromQueue processes the RequestRemoveTracksFromQueue
// event payload. Expects data[0] = []interface{} of float64 positions.
func (q *Queue) handleRemoveTracksFromQueue(data ...any) {
if len(data) < 1 {
q.logger.Error(
"RequestRemoveTracksFromQueue: missing data",
)
return
}
positionsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestRemoveTracksFromQueue: invalid positions type",
"got", data[0],
)
return
}
positions := make([]int, 0, len(positionsRaw))
for _, p := range positionsRaw {
if f, ok := p.(float64); ok {
positions = append(positions, int(f))
}
}
q.RemoveTracks(positions)
}
// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload.
// Expects data[0] = []interface{} of file path strings.
func (q *Queue) handleAddTracksToQueue(data ...any) {
@@ -420,10 +488,11 @@ func (q *Queue) handlePlayTracksNext(data ...any) {
}
// SetQueue replaces the entire queue with new tracks and starts playing.
// It uses a two-phase approach: the start track is resolved immediately so
// playback begins without delay, then the remaining tracks are resolved in
// the background. A generation counter ensures stale background work is
// discarded if SetQueue is called again before it finishes.
// It uses a two-phase approach: the first batch of tracks (up to
// initialBatchSize) is resolved immediately so playback begins and the
// queue panel is populated without delay. The remaining tracks are then
// resolved in the background. A generation counter ensures stale
// background work is discarded if SetQueue is called again.
func (q *Queue) SetQueue(filePaths []string, startIndex int) {
gen := q.setQueueGen.Add(1)
@@ -431,43 +500,76 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
startIndex = 0
}
// Phase 1: resolve only the start track so playback begins immediately.
startMeta := q.lookupTrackMetaBatch([]string{filePaths[startIndex]})
// Phase 1: resolve an initial window of tracks centered on startIndex
// so the queue panel is populated around the playing track immediately.
windowStart := max(0, startIndex-initialBatchSize/2)
windowEnd := min(len(filePaths), windowStart+initialBatchSize)
windowStart = max(0, windowEnd-initialBatchSize)
initialPaths := filePaths[windowStart:windowEnd]
batchMeta := q.lookupTrackMetaBatch(initialPaths)
q.mu.Lock()
startTrackMeta, ok := startMeta[filePaths[startIndex]]
if !ok {
q.logger.Warn(
"Could not find start track in database",
"path", filePaths[startIndex],
)
// Build the initial tracks slice preserving original order.
tracks := make([]Track, 0, len(initialPaths))
for i, fp := range initialPaths {
m, ok := batchMeta[fp]
if !ok {
continue
}
tracks = append(tracks, Track{
AudioFileID: m.AudioFileID,
FilePath: m.FilePath,
Position: int64(i),
Title: m.Title,
Artist: m.Artist,
})
}
if len(tracks) == 0 {
q.logger.Warn("No tracks found in initial batch")
q.mu.Unlock()
return
}
// Build a placeholder slice with only the start track populated.
// Other slots will be filled by the background phase.
q.tracks = []Track{
{
AudioFileID: startTrackMeta.AudioFileID,
FilePath: startTrackMeta.FilePath,
Position: 0,
Title: startTrackMeta.Title,
Artist: startTrackMeta.Artist,
},
}
q.currentIndex = 0
q.tracks = tracks
q.sourcePlaylistID = 0
q.shuffleOrder = nil
// Find the start track within the initial batch.
q.currentIndex = 0
startPath := filePaths[startIndex]
for i, t := range q.tracks {
if t.FilePath == startPath {
q.currentIndex = i
break
}
}
// Start playing immediately.
q.playCurrentTrack()
q.emitQueueChanged()
q.mu.Unlock()
// Phase 2: resolve remaining tracks in a background goroutine.
// Phase 2: if there are more tracks beyond the initial batch,
// resolve them in the background. If everything fits in the initial
// batch we can persist and finish synchronously.
if len(filePaths) <= initialBatchSize {
q.mu.Lock()
q.persistTracks()
q.persistState()
q.mu.Unlock()
return
}
go q.resolveRemainingTracks(gen, filePaths, startIndex)
}
@@ -595,7 +697,12 @@ func (q *Queue) AddTrack(filePath string) {
}
q.persistState()
q.emitQueueChanged()
q.emitTracksModified(
"add",
[]Track{track},
len(q.tracks)-1,
nil,
)
}
// AddTracks appends multiple tracks to the end of the queue.
@@ -607,6 +714,9 @@ func (q *Queue) AddTracks(filePaths []string) {
defer q.mu.Unlock()
wasEmpty := len(q.tracks) == 0
insertIndex := len(q.tracks)
var newTracks []Track
for _, fp := range filePaths {
m, ok := allMeta[fp]
@@ -628,6 +738,8 @@ func (q *Queue) AddTracks(filePaths []string) {
}
q.tracks = append(q.tracks, track)
newTracks = append(newTracks, track)
}
if q.shuffleMode {
@@ -642,7 +754,12 @@ func (q *Queue) AddTracks(filePaths []string) {
q.playCurrentTrack()
}
q.emitQueueChanged()
q.emitTracksModified(
"add",
newTracks,
insertIndex,
nil,
)
}
// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
@@ -704,7 +821,12 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
q.playCurrentTrack()
}
q.emitQueueChanged()
q.emitTracksModified(
"insert",
newTracks,
insertPos,
nil,
)
}
// InsertNext inserts a track right after the currently playing track.
@@ -752,7 +874,12 @@ func (q *Queue) InsertNext(filePath string) {
q.persistTracks()
q.persistState()
q.emitQueueChanged()
q.emitTracksModified(
"insert",
[]Track{track},
insertPos,
nil,
)
}
// RemoveTrack removes a track at the given position from the queue.
@@ -769,6 +896,9 @@ func (q *Queue) RemoveTrack(position int) {
return
}
removingCurrent := q.currentIndex >= 0 &&
position == q.currentIndex
q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
// Adjust current index if needed. A currentIndex of -1 means no track
@@ -788,7 +918,90 @@ func (q *Queue) RemoveTrack(position int) {
q.persistTracks()
q.persistState()
q.emitQueueChanged()
q.emitTracksModified(
"remove",
nil,
0,
[]int{position},
)
if removingCurrent {
q.handleCurrentTrackRemoved()
}
}
// RemoveTracks removes multiple tracks at the given positions from the queue.
// Positions are deduplicated, validated, and removed in descending order so
// that indices remain stable during removal.
func (q *Queue) RemoveTracks(positions []int) {
q.mu.Lock()
defer q.mu.Unlock()
if len(positions) == 0 {
return
}
// Deduplicate and filter out-of-range positions.
seen := make(map[int]bool, len(positions))
valid := make([]int, 0, len(positions))
for _, p := range positions {
if p < 0 || p >= len(q.tracks) || seen[p] {
continue
}
seen[p] = true
valid = append(valid, p)
}
if len(valid) == 0 {
return
}
removedCurrent := q.currentIndex >= 0 && seen[q.currentIndex]
// Sort ascending so we can iterate in reverse for descending removal.
slices.Sort(valid)
// Remove in descending order to keep earlier indices stable.
for i := len(valid) - 1; i >= 0; i-- {
pos := valid[i]
q.tracks = append(q.tracks[:pos], q.tracks[pos+1:]...)
if q.currentIndex >= 0 && pos < q.currentIndex {
q.currentIndex--
} else if pos == q.currentIndex &&
q.currentIndex >= len(q.tracks) &&
len(q.tracks) > 0 {
q.currentIndex = len(q.tracks) - 1
}
}
q.reindexPositions()
if q.shuffleMode {
q.generateShuffleOrder()
}
q.persistTracks()
q.persistState()
q.emitTracksModified(
"remove",
nil,
0,
valid,
)
q.logger.Info(
"Removed tracks from queue",
"count", len(valid),
)
if removedCurrent {
q.handleCurrentTrackRemoved()
}
}
// Next advances to the next track. If the player was paused, the next
@@ -807,7 +1020,7 @@ func (q *Queue) Next() {
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
return
}
@@ -821,7 +1034,7 @@ func (q *Queue) Next() {
q.currentIndex = nextIdx
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
}
// Previous goes to the previous track (or restarts current if >3s in).
@@ -840,7 +1053,7 @@ func (q *Queue) Previous() {
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
return
}
@@ -850,7 +1063,7 @@ func (q *Queue) Previous() {
posSecs, err := q.player.CurrentPositionSeconds()
if err == nil && posSecs > PreviousRestartThreshold {
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
return
}
@@ -860,14 +1073,14 @@ func (q *Queue) Previous() {
if prevIdx == -1 {
// At the beginning — just restart the current track.
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
return
}
q.currentIndex = prevIdx
q.playOrLoadCurrentTrack(wasPlaying)
q.emitQueueChanged()
q.emitIndexChanged()
}
// PlayFromStart restarts playback from the beginning of the queue.
@@ -894,7 +1107,7 @@ func (q *Queue) PlayFromStart() {
}
q.playCurrentTrack()
q.emitQueueChanged()
q.emitIndexChanged()
}
// PlayIndex jumps to and plays the track at the given index.
@@ -917,7 +1130,7 @@ func (q *Queue) PlayIndex(index int) {
q.currentIndex = index
q.playCurrentTrack()
q.emitQueueChanged()
q.emitIndexChanged()
}
// ToggleShuffle toggles shuffle mode on/off.
@@ -934,7 +1147,7 @@ func (q *Queue) ToggleShuffle() {
}
q.persistState()
q.emitQueueChanged()
q.emitModeChanged()
}
// CycleRepeat cycles through repeat modes: off -> all -> one -> off.
@@ -952,7 +1165,7 @@ func (q *Queue) CycleRepeat() {
}
q.persistState()
q.emitQueueChanged()
q.emitModeChanged()
}
// GetState returns the current queue state for the frontend.
@@ -1264,6 +1477,19 @@ func (q *Queue) playCurrentTrack() {
}
}
// handleCurrentTrackRemoved handles the case where the currently loaded
// track was removed from the queue. If tracks remain it loads the track
// now at currentIndex (paused); otherwise it exhausts the queue.
func (q *Queue) handleCurrentTrackRemoved() {
if len(q.tracks) == 0 {
q.onQueueExhausted()
return
}
q.loadCurrentTrack()
}
// onQueueExhausted is called when there are no more tracks to play.
// It unloads the current track, resets the index to -1 (no current track),
// and notifies the frontend.
@@ -1276,7 +1502,7 @@ func (q *Queue) onQueueExhausted() {
q.player.UnloadTrack()
}
q.emitQueueChanged()
q.emitIndexChanged()
q.persistState()
}
@@ -1544,6 +1770,59 @@ func (q *Queue) emitQueueChanged() {
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
}
// emitIndexChanged emits only the current index to the frontend.
func (q *Queue) emitIndexChanged() {
if q.ctx == nil {
return
}
runtime.EventsEmit(
q.ctx,
events.QueueIndexChanged,
IndexChanged{CurrentIndex: q.currentIndex},
)
}
// emitModeChanged emits only the shuffle/repeat mode to the frontend.
func (q *Queue) emitModeChanged() {
if q.ctx == nil {
return
}
runtime.EventsEmit(
q.ctx,
events.QueueModeChanged,
ModeChanged{
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
},
)
}
// emitTracksModified emits a delta update for track list changes.
func (q *Queue) emitTracksModified(
action string,
tracks []Track,
index int,
positions []int,
) {
if q.ctx == nil {
return
}
runtime.EventsEmit(
q.ctx,
events.QueueTracksModified,
TracksModified{
Action: action,
Tracks: tracks,
Index: index,
Positions: positions,
CurrentIndex: q.currentIndex,
},
)
}
// Sentinel errors.
var (
ErrEmptyQueue = errors.New("queue is empty")
@@ -1,13 +1,42 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private queue = new QueueController(this);
private unsubscribeQueue?: () => void;
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
override connectedCallback(): void {
super.connectedCallback();
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
this.unsubscribeQueue = queueStore.subscribe(() => {
const qs = queueStore.getState();
if (
qs.shuffleMode !== this.shuffleMode ||
qs.repeatMode !== this.repeatMode
) {
this.shuffleMode = qs.shuffleMode;
this.repeatMode = qs.repeatMode;
}
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
}
static override styles = css`
#player-control-buttons {
@@ -59,19 +88,19 @@ export class PlayerControls extends LitElement {
};
private handleNextClick = () => {
this.queue.next();
queueStore.next();
};
private handlePreviousClick = () => {
this.queue.previous();
queueStore.previous();
};
private handleShuffleClick = () => {
this.queue.toggleShuffle();
queueStore.toggleShuffle();
};
private handleRepeatClick = () => {
this.queue.cycleRepeat();
queueStore.cycleRepeat();
};
override render() {
@@ -80,8 +109,8 @@ export class PlayerControls extends LitElement {
? this.handlePauseClick
: this.handlePlayClick;
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
const repeatMode = this.queue.repeatMode;
const shuffleClass = this.shuffleMode ? 'active' : '';
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
@@ -358,10 +358,11 @@ export class CoverGrid extends LitElement {
typeof setTimeout
> | null = null;
private pendingFocus: {
row: number;
albumIndex: number;
viewportOffset: number;
} | null = null;
private currentColumnCount = 0;
private isResizing = false;
/* ====================================================================
* Lifecycle
@@ -510,6 +511,12 @@ export class CoverGrid extends LitElement {
private onVisibilityChanged = (
e: VisibilityChangedEvent,
) => {
// Skip saves while a resize reflow is in
// progress — the virtualizer reports
// intermediate positions that would overwrite
// the real scroll position in the store.
if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
@@ -534,10 +541,11 @@ export class CoverGrid extends LitElement {
* open/close, window resize) the grid reflows and
* the pixel scroll position becomes stale.
*
* We compute a fractional album index at a focus
* point before the resize, then after the reflow we
* place that same index back at the same viewport
* offset.
* We identify the album at the viewport center
* before the resize, then after the reflow we
* place that same album back at the same viewport
* offset. Integer album indices ensure zero
* scroll creep across repeated open/close cycles.
*
* If a dropdown is open the expanded album is the
* focus; otherwise the album at the viewport center
@@ -569,6 +577,7 @@ export class CoverGrid extends LitElement {
const pending = this.pendingFocus;
this.pendingFocus = null;
this.isResizing = false;
if (!pending) return;
@@ -585,10 +594,15 @@ export class CoverGrid extends LitElement {
return;
}
// No dropdown — restore the
// center-of-viewport position.
// Derive the album's row under the new
// column count. Both albumIndex and
// newColumns are integers, so newRow is
// also an integer — no fractional drift.
const newRow = Math.floor(
pending.albumIndex / newColumns,
);
const newY =
GRID_PADDING + pending.row * rowStep;
GRID_PADDING + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
@@ -599,6 +613,8 @@ export class CoverGrid extends LitElement {
// Capture on the first event using
// the pre-resize column count.
if (this.pendingFocus === null) {
this.isResizing = true;
this.captureFocusPoint(
container,
rowStep,
@@ -655,12 +671,18 @@ export class CoverGrid extends LitElement {
* If a dropdown is open, the expanded album is the
* focus and its current viewport offset is preserved.
* Otherwise the album at the viewport center is used.
*
* Stores an integer album index and the pixel offset
* from that album's top edge to the viewport top.
* Integer indices ensure zero drift across repeated
* open/close cycles (no fractional accumulation).
*/
private captureFocusPoint(
container: HTMLElement,
rowStep: number,
) {
const { GRID_PADDING } = CoverGrid;
const cols = this.currentColumnCount;
// Prefer the expanded album as focus.
if (this.expandedAlbumId !== null) {
@@ -670,13 +692,13 @@ export class CoverGrid extends LitElement {
if (idx >= 0) {
const albumRow = Math.floor(
idx / this.currentColumnCount,
idx / cols,
);
const albumY =
GRID_PADDING + albumRow * rowStep;
this.pendingFocus = {
row: albumRow,
albumIndex: idx,
viewportOffset:
albumY - container.scrollTop,
};
@@ -685,17 +707,30 @@ export class CoverGrid extends LitElement {
}
}
// Fall back to the viewport center.
const halfViewport =
container.clientHeight / 2;
// Fall back to the album whose row contains
// the viewport center.
const centerY =
container.scrollTop + halfViewport;
const row =
(centerY - GRID_PADDING) / rowStep;
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - GRID_PADDING) /
rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
Math.max(0, this.albums.length - 1),
);
// Pixel offset from that album's top edge
// to the viewport top — used exactly once in
// restoreScroll, never fed back.
const albumY =
GRID_PADDING + centerRow * rowStep;
this.pendingFocus = {
row,
viewportOffset: halfViewport,
albumIndex,
viewportOffset:
albumY - container.scrollTop,
};
}
@@ -1,13 +1,23 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { CreatePlaylist } from '@go/playlist/Service';
import {
CreatePlaylist,
RemoveTracksFromPlaylist,
} from '@go/playlist/Service';
import type { playlist } from '@go/models';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import { PlayerController } from '@store/controllers/player-controller';
import { PlaylistController } from '@store/controllers/playlist-controller';
import '@components/track-info/track-info';
import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
const SCROLL_DEBOUNCE_MS = 100;
@@ -18,16 +28,131 @@ interface PlaylistEntry {
}
@customElement('playlist-view')
export class PlaylistView extends LitElement {
private queue = new QueueController(this);
export class PlaylistView
extends LitElement
implements SelectionHost
{
private player = new PlayerController(this);
private playlistCtrl = new PlaylistController(this);
private scrollDebounceTimer: ReturnType<typeof setTimeout> | null =
null;
private selection = new SelectionController(this);
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
/**
* Index of the playlist whose tracks are currently
* selectable. -1 means no active selection scope.
*/
private activePlaylistIndex = -1;
@state() private entries: PlaylistEntry[] = [];
@state() private loading = true;
@state() private creating = false;
@state() private newPlaylistName = '';
@state() private contextMenuOpen = false;
@state() private playlistSubmenuOpen = false;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
private closeContextMenuHandler = () =>
this.closeContextMenu();
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('track-item') &&
this.shadowRoot?.contains(el),
);
if (!isTrackClick) {
this.selection.clear();
}
};
// =================================================================
// SelectionHost interface
// =================================================================
getItemKey(index: number): string | undefined {
if (this.activePlaylistIndex < 0) return undefined;
const entry =
this.entries[this.activePlaylistIndex];
if (
!entry ||
index < 0 ||
index >= entry.tracks.length
) {
return undefined;
}
return String(index);
}
getItemCount(): number {
if (this.activePlaylistIndex < 0) return 0;
const entry =
this.entries[this.activePlaylistIndex];
return entry?.tracks.length ?? 0;
}
/**
* Return the selected playlist track IDs (database IDs)
* in order, for removal operations.
*/
private getSelectedTrackIDs(): number[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.ID);
}
/**
* Derive file paths from selected indices for
* operations that need file paths.
*/
private getSelectedFilePaths(): string[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.FilePath);
}
/**
* Ensure the selection scope matches the given playlist
* index. If switching playlists, clear the old selection.
*/
private ensureSelectionScope(
playlistIndex: number,
): void {
if (
this.activePlaylistIndex !== playlistIndex
) {
this.selection.clear();
this.activePlaylistIndex = playlistIndex;
}
}
static override styles = css`
:host {
@@ -139,7 +264,8 @@ export class PlaylistView extends LitElement {
}
.playlist-item {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid
rgba(255, 255, 255, 0.05);
}
.playlist-header {
@@ -219,7 +345,27 @@ export class PlaylistView extends LitElement {
.track-item {
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
border-bottom: 1px solid
rgba(255, 255, 255, 0.03);
cursor: default;
user-select: none;
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.track-item.selected {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
color: #ffd43b;
}
.track-item.selected.active {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item:last-child {
@@ -258,11 +404,60 @@ export class PlaylistView extends LitElement {
.empty-state p {
margin: 4px 0;
}
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadPlaylists();
document.addEventListener(
'click',
this.closeContextMenuHandler,
);
document.addEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.addEventListener(
'click',
this.clearSelectionHandler,
);
}
override disconnectedCallback() {
@@ -272,6 +467,19 @@ export class PlaylistView extends LitElement {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
document.removeEventListener(
'click',
this.closeContextMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.removeEventListener(
'click',
this.clearSelectionHandler,
);
}
private get scrollContainer(): HTMLElement | null {
@@ -318,7 +526,10 @@ export class PlaylistView extends LitElement {
tracks: p.Tracks ?? [],
}));
} catch (err) {
console.error('Failed to load playlists:', err);
console.error(
'Failed to load playlists:',
err,
);
this.entries = [];
} finally {
this.loading = false;
@@ -333,6 +544,15 @@ export class PlaylistView extends LitElement {
if (!entry) return;
// If collapsing the active playlist, clear selection.
if (
entry.expanded &&
this.activePlaylistIndex === index
) {
this.selection.clear();
this.activePlaylistIndex = -1;
}
this.entries = this.entries.map((e, i) =>
i === index
? { ...e, expanded: !e.expanded }
@@ -345,10 +565,211 @@ export class PlaylistView extends LitElement {
if (!entry || entry.tracks.length === 0) return;
const filePaths = entry.tracks.map((t) => t.FilePath);
this.queue.setQueue(filePaths, 0);
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, 0);
};
// =================================================================
// Track selection & context menu
// =================================================================
private handleTrackClick(
e: MouseEvent,
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
this.ensureSelectionScope(playlistIndex);
this.selection.handleItemClick(
e,
String(trackIndex),
trackIndex,
);
}
private handleTrackDblClick(
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
const entry = this.entries[playlistIndex];
if (!entry) return;
this.selection.clear();
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, trackIndex);
}
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
playlistIndex: number,
) {
e.preventDefault();
e.stopPropagation();
this.ensureSelectionScope(playlistIndex);
this.selection.handleContextMenu(
String(trackIndex),
);
this.contextMenuOpen = true;
// Position at mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
}
private onContextMenuAction(action: string) {
const filePaths =
this.getSelectedFilePaths();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
queueStore.playTracksNext(filePaths);
break;
case 'remove':
void this.removeSelectedTracks();
break;
}
this.closeContextMenu(true);
}
private async removeSelectedTracks() {
if (this.activePlaylistIndex < 0) return;
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return;
const trackIDs = this.getSelectedTrackIDs();
if (trackIDs.length === 0) return;
try {
await RemoveTracksFromPlaylist(
entry.summary.ID,
trackIDs,
);
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error(
'Failed to remove tracks:',
err,
);
}
}
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async showPlaylistSubmenu() {
if (this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = true;
await this.updateComplete;
const submenu = this.playlistSubmenuPopup;
const trigger =
this.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
(submenu as any).anchor = trigger;
(submenu as any).active = true;
}
const picker = this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
private isActiveTrack(
track: playlist.Track,
): boolean {
const currentTrack = this.player.currentTrack;
if (!currentTrack) return false;
return currentTrack.filePath === track.FilePath;
}
// =================================================================
// Create playlist
// =================================================================
private handleNewPlaylistClick = () => {
this.creating = true;
this.newPlaylistName = '';
@@ -379,7 +800,10 @@ export class PlaylistView extends LitElement {
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error('Failed to create playlist:', err);
console.error(
'Failed to create playlist:',
err,
);
}
};
@@ -396,6 +820,10 @@ export class PlaylistView extends LitElement {
}
};
// =================================================================
// Render
// =================================================================
override render() {
return html`
<div class="header">
@@ -409,17 +837,120 @@ export class PlaylistView extends LitElement {
</button>
</div>
${this.creating ? this.renderCreateForm() : nothing}
${this.creating
? this.renderCreateForm()
: nothing}
${this.loading
? html`<div class="loading">
Loading playlists...
</div>`
: this.renderPlaylistList()}
<wa-popup
id="context-menu"
placement="bottom-start"
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
@playlist-action-complete=${this
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
`;
}
private renderCreateForm() {
const canCreate = this.newPlaylistName.trim().length > 0;
const canCreate =
this.newPlaylistName.trim().length > 0;
return html`
<div class="create-form">
@@ -430,7 +961,9 @@ export class PlaylistView extends LitElement {
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
/>
<button @click=${this.handleCancelCreate}>
<button
@click=${this.handleCancelCreate}
>
Cancel
</button>
<button
@@ -458,7 +991,10 @@ export class PlaylistView extends LitElement {
}
return html`
<ul class="playlist-list" @scroll=${this.onScroll}>
<ul
class="playlist-list"
@scroll=${this.onScroll}
>
${this.entries.map((entry, i) =>
this.renderPlaylistItem(entry, i),
)}
@@ -466,16 +1002,19 @@ export class PlaylistView extends LitElement {
`;
}
private renderPlaylistItem(entry: PlaylistEntry, index: number) {
private renderPlaylistItem(
entry: PlaylistEntry,
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel =
`${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
return html`
<li class="playlist-item">
<div
class="playlist-header"
@click=${() => this.handleToggle(index)}
@click=${() =>
this.handleToggle(index)}
>
<wa-icon
class="chevron ${entry.expanded
@@ -495,7 +1034,10 @@ export class PlaylistView extends LitElement {
</span>
</div>
${entry.expanded
? this.renderPlaylistBody(entry, index)
? this.renderPlaylistBody(
entry,
index,
)
: nothing}
</li>
`;
@@ -503,7 +1045,7 @@ export class PlaylistView extends LitElement {
private renderPlaylistBody(
entry: PlaylistEntry,
index: number,
playlistIndex: number,
) {
if (entry.tracks.length === 0) {
return html`
@@ -522,7 +1064,9 @@ export class PlaylistView extends LitElement {
class="play-all-button"
@click=${(e: Event) => {
e.stopPropagation();
this.handlePlayAll(index);
this.handlePlayAll(
playlistIndex,
);
}}
>
<wa-icon name="play"></wa-icon>
@@ -530,16 +1074,60 @@ export class PlaylistView extends LitElement {
</button>
</div>
${entry.tracks.map(
(track) => html`
<div class="track-item">
<track-info
.trackTitle=${track.Title}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
</div>
`,
(track, trackIndex) => {
const active =
this.isActiveTrack(track);
const selected =
this.activePlaylistIndex ===
playlistIndex &&
this.selection.isSelected(
String(trackIndex),
);
const classes = [
'track-item',
active ? 'active' : '',
selected ? 'selected' : '',
]
.filter(Boolean)
.join(' ');
return html`
<div
class=${classes}
@click=${(
e: MouseEvent,
) =>
this.handleTrackClick(
e,
track,
trackIndex,
playlistIndex,
)}
@dblclick=${() =>
this.handleTrackDblClick(
track,
trackIndex,
playlistIndex,
)}
@contextmenu=${(
e: MouseEvent,
) =>
this.handleTrackContextMenu(
e,
trackIndex,
playlistIndex,
)}
>
<track-info
.trackTitle=${track.Title}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
</div>
`;
},
)}
</div>
`;
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,10 @@ import { library } from '@go/models';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { formatMilliseconds } from '@utils/time';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import '@lit-labs/virtualizer';
import type {
@@ -23,17 +25,14 @@ const DEFAULT_DURATION_WIDTH = 80;
const COLUMN_COUNT = 3;
@customElement('track-list')
export class TrackList extends LitElement {
export class TrackList extends LitElement implements SelectionHost {
private player = new PlayerController(this);
private queue = new QueueController(this);
private libraryCtrl = new LibraryController(this);
private selection = new SelectionController(this);
@state()
private tracks: library.Track[] = [];
@state()
private selectedTracks: Set<string> = new Set();
@state()
private contextMenuOpen = false;
@@ -49,11 +48,24 @@ export class TrackList extends LitElement {
@query('lit-virtualizer')
private virtualizer!: LitVirtualizer;
private lastSelectedIndex: number | null = null;
private lastActiveTrackPath: string | null = null;
private closeHandler = () => this.closeContextMenu();
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('track-row') &&
this.shadowRoot?.contains(el),
);
if (!isTrackClick) {
this.selection.clear();
}
};
@state()
private columnWidths: number[] = [];
@@ -64,6 +76,22 @@ export class TrackList extends LitElement {
private flowLayout = flow();
private hasRestoredScroll = false;
// =================================================================
// SelectionHost interface
// =================================================================
getItemKey(index: number): string | undefined {
return this.tracks[index]?.FilePath;
}
getItemCount(): number {
return this.tracks.length;
}
onSelectionChanged(): void {
this.virtualizer?.requestUpdate();
}
private get gridTemplateColumns(): string {
if (this.columnWidths.length === 0) {
return '1fr 1fr 80px';
@@ -361,6 +389,7 @@ export class TrackList extends LitElement {
this.loadTracks();
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
document.addEventListener('click', this.clearSelectionHandler);
document.addEventListener('mousemove', this.onColResizeMove);
document.addEventListener('mouseup', this.onColResizeEnd);
@@ -380,6 +409,7 @@ export class TrackList extends LitElement {
super.disconnectedCallback();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
document.removeEventListener('click', this.clearSelectionHandler);
document.removeEventListener('mousemove', this.onColResizeMove);
document.removeEventListener('mouseup', this.onColResizeEnd);
@@ -399,10 +429,6 @@ export class TrackList extends LitElement {
);
}
if (changed.has('selectedTracks')) {
this.virtualizer?.requestUpdate();
}
const currentPath =
this.player.currentTrack?.filePath ?? null;
@@ -455,8 +481,7 @@ export class TrackList extends LitElement {
try {
const tracks = await this.libraryCtrl.getTracks();
this.tracks = tracks;
this.selectedTracks = new Set();
this.lastSelectedIndex = null;
this.selection.clear();
await this.updateComplete;
if (this.isConnected && this.virtualizer) {
@@ -494,94 +519,24 @@ export class TrackList extends LitElement {
this.libraryCtrl.setScrollPosition('tracks', first);
};
private getSelectedFilePaths(): string[] {
return this.tracks
.filter((t) => this.selectedTracks.has(t.FilePath))
.map((t) => t.FilePath);
}
private selectRange(from: number, to: number): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const paths = new Set<string>();
for (let i = start; i <= end; i++) {
const track = this.tracks[i];
if (track) {
paths.add(track.FilePath);
}
}
return paths;
}
private onTrackRowClick(
e: MouseEvent,
track: library.Track,
index: number,
) {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && this.lastSelectedIndex !== null) {
const range = this.selectRange(
this.lastSelectedIndex,
index,
);
if (isCtrl) {
// Ctrl+Shift: add range to existing selection.
const next = new Set(this.selectedTracks);
for (const path of range) {
next.add(path);
}
this.selectedTracks = next;
} else {
// Shift only: add range to existing selection.
const next = new Set(this.selectedTracks);
for (const path of range) {
next.add(path);
}
this.selectedTracks = next;
}
// Don't update anchor on shift-click so user can
// adjust the range endpoint with another shift-click.
} else if (isCtrl) {
const next = new Set(this.selectedTracks);
if (next.has(track.FilePath)) {
next.delete(track.FilePath);
} else {
next.add(track.FilePath);
}
this.selectedTracks = next;
this.lastSelectedIndex = index;
} else {
this.selectedTracks = new Set([track.FilePath]);
this.lastSelectedIndex = index;
}
this.selection.handleItemClick(e, track.FilePath, index);
}
private onTrackRowDblClick(track: library.Track) {
this.selectedTracks = new Set();
this.queue.setQueue([track.FilePath], 0);
this.selection.clear();
queueStore.setQueue([track.FilePath], 0);
}
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
e.preventDefault();
e.stopPropagation();
if (!this.selectedTracks.has(track.FilePath)) {
this.selectedTracks = new Set([track.FilePath]);
}
this.selection.handleContextMenu(track.FilePath);
this.contextMenuOpen = true;
// Position the popup at the mouse cursor using a virtual anchor.
@@ -609,19 +564,19 @@ export class TrackList extends LitElement {
}
private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths();
const filePaths = this.selection.getSelectedKeysOrdered();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
this.queue.setQueue(filePaths, 0);
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
this.queue.addTracksToQueue(filePaths);
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
this.queue.playTracksNext(filePaths);
queueStore.playTracksNext(filePaths);
break;
}
@@ -635,7 +590,7 @@ export class TrackList extends LitElement {
this.contextMenuOpen = false;
if (clearSelection) {
this.selectedTracks = new Set();
this.selection.clear();
}
const popup = this.contextMenuPopup;
@@ -696,7 +651,7 @@ export class TrackList extends LitElement {
index: number,
): unknown => {
const active = this.isActiveTrack(track);
const selected = this.selectedTracks.has(track.FilePath);
const selected = this.selection.isSelected(track.FilePath);
const classes = [
'track-row',
@@ -803,10 +758,10 @@ export class TrackList extends LitElement {
placement="right-start"
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen && this.selectedTracks.size > 0
${this.playlistSubmenuOpen && this.selection.hasSelection
? html`
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
.filePaths=${this.selection.getSelectedKeysOrdered()}
@playlist-action-complete=${this.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
+4
View File
@@ -22,6 +22,9 @@ export const Events = {
// Queue events
QueueChanged: "QueueChanged",
QueueIndexChanged: "QueueIndexChanged",
QueueModeChanged: "QueueModeChanged",
QueueTracksModified: "QueueTracksModified",
RequestNext: "RequestNext",
RequestPrevious: "RequestPrevious",
RequestSetQueue: "RequestSetQueue",
@@ -33,6 +36,7 @@ export const Events = {
RequestAddTracksToQueue: "RequestAddTracksToQueue",
RequestPlayTracksNext: "RequestPlayTracksNext",
RequestPlayQueueIndex: "RequestPlayQueueIndex",
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
// Library events
LibraryScanComplete: "LibraryScanComplete",
@@ -95,6 +95,10 @@ export class QueueController implements ReactiveController {
queueStore.removeFromQueue(position);
}
removeTracksFromQueue(positions: number[]): void {
queueStore.removeTracksFromQueue(positions);
}
addTracksToQueue(filePaths: string[]): void {
queueStore.addTracksToQueue(filePaths);
}
+85
View File
@@ -21,6 +21,24 @@ export interface QueueState {
sourcePlaylistId: number;
}
// Delta event payloads (mirror Go structs in backend/queue/queue.go).
interface IndexChanged {
currentIndex: number;
}
interface ModeChanged {
shuffleMode: boolean;
repeatMode: RepeatMode;
}
interface TracksModified {
action: string;
tracks?: QueueTrack[];
index: number;
positions?: number[];
currentIndex: number;
}
type Subscriber = () => void;
class QueueStore {
@@ -44,6 +62,7 @@ class QueueStore {
// ===================================================================
private initializeEventListeners(): void {
// Full-state sync (startup, SetQueue).
EventsOn(Events.QueueChanged, (queueState: QueueState) => {
this.state = {
tracks: queueState.tracks ?? [],
@@ -54,6 +73,68 @@ class QueueStore {
};
this.notify();
});
// Delta: index-only change (Next, Previous, PlayIndex, etc.).
EventsOn(
Events.QueueIndexChanged,
(payload: IndexChanged) => {
this.state.currentIndex = payload.currentIndex;
this.notify();
},
);
// Delta: mode-only change (ToggleShuffle, CycleRepeat).
EventsOn(
Events.QueueModeChanged,
(payload: ModeChanged) => {
this.state.shuffleMode = payload.shuffleMode;
this.state.repeatMode = payload.repeatMode;
this.notify();
},
);
// Delta: track list mutation (Add, Insert, Remove).
EventsOn(
Events.QueueTracksModified,
(payload: TracksModified) => {
this.applyTracksDelta(payload);
this.notify();
},
);
}
private applyTracksDelta(delta: TracksModified): void {
const tracks = this.state.tracks;
switch (delta.action) {
case 'add':
if (delta.tracks) {
this.state.tracks = [...tracks, ...delta.tracks];
}
break;
case 'insert':
if (delta.tracks) {
const before = tracks.slice(0, delta.index);
const after = tracks.slice(delta.index);
this.state.tracks = [...before, ...delta.tracks, ...after];
}
break;
case 'remove':
if (delta.positions) {
const removeSet = new Set(delta.positions);
this.state.tracks = tracks.filter(
(_, i) => !removeSet.has(i),
);
}
break;
}
this.state.currentIndex = delta.currentIndex;
}
// ===================================================================
@@ -93,6 +174,10 @@ class QueueStore {
EventsEmit(Events.RequestRemoveFromQueue, position);
}
removeTracksFromQueue(positions: number[]): void {
EventsEmit(Events.RequestRemoveTracksFromQueue, positions);
}
addTracksToQueue(filePaths: string[]): void {
EventsEmit(Events.RequestAddTracksToQueue, filePaths);
}
+198
View File
@@ -0,0 +1,198 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
/**
* Host interface for components using the SelectionController.
* The host must provide a way to look up item keys by index and
* report the total item count.
*/
export interface SelectionHost extends ReactiveControllerHost {
getItemKey(index: number): string | undefined;
getItemCount(): number;
onSelectionChanged?(): void;
}
/**
* Reusable selection controller that manages multi-select state
* with click, Ctrl+click, and Shift+click semantics.
*/
export class SelectionController implements ReactiveController {
private host: SelectionHost;
private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null;
constructor(host: SelectionHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
// No-op; state is component-local.
}
hostDisconnected(): void {
// No-op.
}
// =================================================================
// STATE ACCESSORS
// =================================================================
/** The current set of selected item keys. */
get selectedItems(): ReadonlySet<string> {
return this._selectedItems;
}
/** Whether any items are currently selected. */
get hasSelection(): boolean {
return this._selectedItems.size > 0;
}
/** Number of selected items. */
get selectionCount(): number {
return this._selectedItems.size;
}
/** Check whether a specific key is selected. */
isSelected(key: string): boolean {
return this._selectedItems.has(key);
}
// =================================================================
// ACTIONS
// =================================================================
/**
* Handle a click on an item row. Supports plain click (replace
* selection), Ctrl/Cmd+click (toggle), Shift+click (range), and
* Ctrl+Shift+click (add range to existing selection).
*/
handleItemClick(
e: MouseEvent,
key: string,
index: number,
): void {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && this.lastSelectedIndex !== null) {
const range = this.selectRange(
this.lastSelectedIndex,
index,
);
// Both Shift and Ctrl+Shift add the range to the
// existing selection.
const next = new Set(this._selectedItems);
for (const path of range) {
next.add(path);
}
this._selectedItems = next;
// Don't update anchor on shift-click so the user can
// adjust the range endpoint with another shift-click.
} else if (isCtrl) {
const next = new Set(this._selectedItems);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
this._selectedItems = next;
this.lastSelectedIndex = index;
} else {
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
}
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Handle a right-click (context menu) on an item. If the clicked
* item is not already selected, replace the selection with just
* that item. Otherwise preserve the existing multi-selection.
*/
handleContextMenu(key: string): void {
if (!this._selectedItems.has(key)) {
this._selectedItems = new Set([key]);
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
}
/** Clear the entire selection. */
clear(): void {
if (this._selectedItems.size === 0) return;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Return the selected keys in the order they appear in the host's
* item list. This preserves positional ordering for queue operations.
*/
getSelectedKeysOrdered(): string[] {
const count = this.host.getItemCount();
const result: string[] = [];
for (let i = 0; i < count; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined && this._selectedItems.has(key)) {
result.push(key);
}
}
return result;
}
/**
* Return the selected indices in ascending order.
*/
getSelectedIndices(): number[] {
const count = this.host.getItemCount();
const result: number[] = [];
for (let i = 0; i < count; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined && this._selectedItems.has(key)) {
result.push(i);
}
}
return result;
}
// =================================================================
// INTERNALS
// =================================================================
/**
* Build a Set of keys for all items between two indices (inclusive),
* handling either direction.
*/
private selectRange(from: number, to: number): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const keys = new Set<string>();
for (let i = start; i <= end; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined) {
keys.add(key);
}
}
return keys;
}
}
+2
View File
@@ -15,4 +15,6 @@ export function GetAllPlaylistsWithTracks():Promise<Array<playlist.WithTracks>>;
export function GetPlaylistTracks(arg1:number):Promise<Array<playlist.Track>>;
export function RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
+4
View File
@@ -26,6 +26,10 @@ export function GetPlaylistTracks(arg1) {
return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1);
}
export function RemoveTracksFromPlaylist(arg1, arg2) {
return window['go']['playlist']['Service']['RemoveTracksFromPlaylist'](arg1, arg2);
}
export function SetContext(arg1) {
return window['go']['playlist']['Service']['SetContext'](arg1);
}