diff --git a/backend/events/events.go b/backend/events/events.go index 3462a7d..aeece66 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -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. diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index f66ce06..318f5d6 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -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, diff --git a/backend/queue/queue.go b/backend/queue/queue.go index dd56646..3323d7f 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -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") diff --git a/frontend/src/components/audio-player/controls/player-controls.ts b/frontend/src/components/audio-player/controls/player-controls.ts index 7580f5c..9f2628f 100644 --- a/frontend/src/components/audio-player/controls/player-controls.ts +++ b/frontend/src/components/audio-player/controls/player-controls.ts @@ -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' : '', diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 024b20a..ae1311c 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -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, }; } diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index bc873e7..c02d168 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -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 | 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`
@@ -409,17 +837,120 @@ export class PlaylistView extends LitElement {
- ${this.creating ? this.renderCreateForm() : nothing} + ${this.creating + ? this.renderCreateForm() + : nothing} ${this.loading ? html`
Loading playlists...
` : this.renderPlaylistList()} + + + ${this.contextMenuOpen + ? html` +
+ + this.onContextMenuAction( + 'play', + )} + > + + Play + + + this.onContextMenuAction( + 'add-to-queue', + )} + > + + Add to Queue + + + this.onContextMenuAction( + 'play-next', + )} + > + + Play Next + + + this.onContextMenuAction( + 'remove', + )} + > + + Remove from Playlist + + + this.showPlaylistSubmenu()} + @click=${(e: Event) => { + e.stopPropagation(); + void this.showPlaylistSubmenu(); + }} + > + + Add to Playlist + + ▶ + + +
+ ` + : nothing} +
+ + + ${this.playlistSubmenuOpen && + this.selection.hasSelection + ? html` + + e.stopPropagation()} + > + ` + : nothing} + `; } private renderCreateForm() { - const canCreate = this.newPlaylistName.trim().length > 0; + const canCreate = + this.newPlaylistName.trim().length > 0; return html`
@@ -430,7 +961,9 @@ export class PlaylistView extends LitElement { @input=${this.handleInputChange} @keydown=${this.handleInputKeydown} /> -
${entry.tracks.map( - (track) => html` -
- -
- `, + (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` +
+ this.handleTrackClick( + e, + track, + trackIndex, + playlistIndex, + )} + @dblclick=${() => + this.handleTrackDblClick( + track, + trackIndex, + playlistIndex, + )} + @contextmenu=${( + e: MouseEvent, + ) => + this.handleTrackContextMenu( + e, + trackIndex, + playlistIndex, + )} + > + +
+ `; + }, )} `; diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index e466f5c..fe95544 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -1,7 +1,13 @@ import { LitElement, html, css, nothing, unsafeCSS } from 'lit'; -import { customElement, property, state, query } from 'lit/decorators.js'; +import { + customElement, + property, + 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 { QueueController } from '@store/controllers/queue-controller'; import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; @@ -9,427 +15,841 @@ import '@lit-labs/virtualizer'; import type { LitVirtualizer } from '@lit-labs/virtualizer'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import type { QueueTrack } from '@store/queue-store'; +import { SelectionController } from '@utils/selection-controller'; +import type { SelectionHost } from '@utils/selection-controller'; const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 320; @customElement('queue-panel') -export class QueuePanel extends LitElement { - private queue = new QueueController(this); +export class QueuePanel + extends LitElement + implements SelectionHost +{ + private queue = new QueueController(this); + private selection = new SelectionController(this); - @property({ type: Boolean, reflect: true }) - open = false; + @property({ type: Boolean, reflect: true }) + open = false; - @state() - private isDragging = false; + @state() + private isDragging = false; - @state() - private playlistPickerOpen = false; + @state() + private playlistPickerOpen = false; - @query('#add-to-playlist-popup') - private addToPlaylistPopup!: HTMLElement; + @state() + private contextMenuOpen = false; - @query('lit-virtualizer') - private virtualizer!: LitVirtualizer; + @state() + private playlistSubmenuOpen = false; - private closePickerHandler = (e: MouseEvent) => { - const path = e.composedPath(); - const popup = this.addToPlaylistPopup; - const btn = this.shadowRoot?.querySelector('.add-to-playlist-button'); + @query('#add-to-playlist-popup') + private addToPlaylistPopup!: HTMLElement; - if (popup && !path.includes(popup) && (!btn || !path.includes(btn))) { - this.closePlaylistPicker(); - } - }; + @query('#context-menu') + private contextMenuPopup!: HTMLElement; - private panelWidth = DEFAULT_WIDTH; - private flowLayout = flow(); + @query('#playlist-submenu') + private playlistSubmenuPopup!: HTMLElement; - /** Track the last currentIndex so we only auto-scroll on actual track changes. */ - private lastScrolledIndex = -1; + @query('lit-virtualizer') + private virtualizer!: LitVirtualizer; - static override styles = css` - :host { - flex-shrink: 0; - width: 0; - overflow: hidden; - background-color: #212529; - display: flex; - flex-direction: row; + private closePickerHandler = (e: MouseEvent) => { + const path = e.composedPath(); + const popup = this.addToPlaylistPopup; + const btn = this.shadowRoot?.querySelector( + '.add-to-playlist-button', + ); + + if ( + popup && + !path.includes(popup) && + (!btn || !path.includes(btn)) + ) { + this.closePlaylistPicker(); + } + }; + + 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(); + } + }; + + private panelWidth = DEFAULT_WIDTH; + private flowLayout = flow(); + + /** + * Track the last currentIndex so we only auto-scroll + * on actual track changes. + */ + private lastScrolledIndex = -1; + + // ================================================================= + // SelectionHost interface + // ================================================================= + + getItemKey(index: number): string | undefined { + if (index < 0 || index >= this.queue.tracks.length) { + return undefined; + } + + return String(index); } - :host([open]) { - width: var(--queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px); - border-left: 1px solid #333; + getItemCount(): number { + return this.queue.tracks.length; } - .resize-handle { - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - cursor: col-resize; - background-color: transparent; - transition: background-color 0.15s ease; - z-index: 10; + onSelectionChanged(): void { + this.virtualizer?.requestUpdate(); } - .resize-handle:hover, - .resize-handle.dragging { - background-color: #6c757d; + static override styles = css` + :host { + flex-shrink: 0; + width: 0; + overflow: hidden; + background-color: #212529; + display: flex; + flex-direction: row; + } + + :host([open]) { + width: var( + --queue-width, + ${unsafeCSS(DEFAULT_WIDTH)}px + ); + border-left: 1px solid #333; + } + + .resize-handle { + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + cursor: col-resize; + background-color: transparent; + transition: background-color 0.15s ease; + z-index: 10; + } + + .resize-handle:hover, + .resize-handle.dragging { + background-color: #6c757d; + } + + .panel-content { + position: relative; + display: flex; + flex-direction: column; + min-width: ${unsafeCSS(MIN_WIDTH)}px; + flex: 1; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #333; + flex-shrink: 0; + } + + .header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + } + + .add-to-playlist-button { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + } + + .add-to-playlist-button:hover { + color: #ffd43b; + } + + .add-to-playlist-button:disabled { + color: #555; + cursor: not-allowed; + } + + #add-to-playlist-popup { + z-index: 210; + } + + lit-virtualizer { + flex: 1; + overflow-y: auto; + } + + .track-item { + display: flex; + align-items: center; + padding: 8px 16px; + gap: 12px; + border-bottom: 1px solid + rgba(255, 255, 255, 0.05); + cursor: default; + user-select: none; + width: 100%; + box-sizing: border-box; + } + + .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); + } + + .track-item.selected.active { + background-color: rgba(100, 160, 255, 0.15); + } + + .track-position { + font-size: 12px; + color: #888; + min-width: 20px; + text-align: right; + } + + .track-item.active .track-position { + color: #ffd43b; + } + + .track-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .track-title { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .track-item.active .track-title { + color: #ffd43b; + } + + .track-artist { + font-size: 11px; + color: #b3b3b3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .remove-button { + background: none; + border: none; + color: #888; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.15s; + } + + .track-item:hover .remove-button { + opacity: 1; + } + + .remove-button:hover { + color: #ff6b6b; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: #b3b3b3; + text-align: center; + gap: 8px; + } + + .empty-state wa-icon { + font-size: 32px; + } + + #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.style.setProperty( + '--queue-width', + `${this.panelWidth}px`, + ); + document.addEventListener( + 'mousemove', + this.handleMouseMove, + ); + document.addEventListener( + 'mouseup', + this.handleMouseUp, + ); + document.addEventListener( + 'click', + this.closePickerHandler, + ); + document.addEventListener( + 'click', + this.closeContextMenuHandler, + ); + document.addEventListener( + 'contextmenu', + this.closeContextMenuHandler, + ); + document.addEventListener( + 'click', + this.clearSelectionHandler, + ); } - .panel-content { - position: relative; - display: flex; - flex-direction: column; - min-width: ${unsafeCSS(MIN_WIDTH)}px; - flex: 1; + override disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener( + 'mousemove', + this.handleMouseMove, + ); + document.removeEventListener( + 'mouseup', + this.handleMouseUp, + ); + document.removeEventListener( + 'click', + this.closePickerHandler, + ); + document.removeEventListener( + 'click', + this.closeContextMenuHandler, + ); + document.removeEventListener( + 'contextmenu', + this.closeContextMenuHandler, + ); + document.removeEventListener( + 'click', + this.clearSelectionHandler, + ); } - .header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-bottom: 1px solid #333; - flex-shrink: 0; + override updated() { + const currentIndex = this.queue.currentIndex; + + // Auto-scroll to the active track when it changes. + if ( + currentIndex >= 0 && + currentIndex !== this.lastScrolledIndex && + this.virtualizer + ) { + this.lastScrolledIndex = currentIndex; + requestAnimationFrame(() => { + this.virtualizer?.scrollToIndex( + currentIndex, + 'center', + ); + }); + } } - .header h3 { - margin: 0; - font-size: 14px; - font-weight: 600; + private async handleAddToPlaylist() { + if (this.queue.tracks.length === 0) return; + + this.playlistPickerOpen = !this.playlistPickerOpen; + + await this.updateComplete; + + const popup = this.addToPlaylistPopup; + const btn = this.shadowRoot?.querySelector( + '.add-to-playlist-button', + ); + + if (popup && btn) { + (popup as any).anchor = btn; + (popup as any).active = this.playlistPickerOpen; + } + + if (this.playlistPickerOpen) { + const picker = this.shadowRoot?.querySelector( + 'playlist-picker', + ) as PlaylistPicker | null; + + picker?.reset(); + } } - .add-to-playlist-button { - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; + private closePlaylistPicker() { + if (!this.playlistPickerOpen) return; + + this.playlistPickerOpen = false; + + const popup = this.addToPlaylistPopup; + + if (popup) { + (popup as any).active = false; + } } - .add-to-playlist-button:hover { - color: #ffd43b; - } + private onPlaylistActionComplete = () => { + this.closePlaylistPicker(); + }; - .add-to-playlist-button:disabled { - color: #555; - cursor: not-allowed; - } + // ================================================================= + // Selection & click handlers + // ================================================================= - #add-to-playlist-popup { - z-index: 210; - } - - lit-virtualizer { - flex: 1; - overflow-y: auto; - } - - .track-item { - display: flex; - align-items: center; - padding: 8px 16px; - gap: 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - cursor: pointer; - width: 100%; - box-sizing: border-box; - } - - .track-item:hover { - background-color: rgba(255, 255, 255, 0.05); - } - - .track-item.active { - background-color: rgba(255, 212, 59, 0.1); - } - - .track-position { - font-size: 12px; - color: #888; - min-width: 20px; - text-align: right; - } - - .track-item.active .track-position { - color: #ffd43b; - } - - .track-details { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .track-title { - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .track-item.active .track-title { - color: #ffd43b; - } - - .track-artist { - font-size: 11px; - color: #b3b3b3; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .remove-button { - background: none; - border: none; - color: #888; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - opacity: 0; - transition: opacity 0.15s; - } - - .track-item:hover .remove-button { - opacity: 1; - } - - .remove-button:hover { - color: #ff6b6b; - } - - .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px 20px; - color: #b3b3b3; - text-align: center; - gap: 8px; - } - - .empty-state wa-icon { - font-size: 32px; - } - `; - - override connectedCallback() { - super.connectedCallback(); - this.style.setProperty('--queue-width', `${this.panelWidth}px`); - document.addEventListener('mousemove', this.handleMouseMove); - document.addEventListener('mouseup', this.handleMouseUp); - document.addEventListener('click', this.closePickerHandler); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener('mousemove', this.handleMouseMove); - document.removeEventListener('mouseup', this.handleMouseUp); - document.removeEventListener('click', this.closePickerHandler); - } - - override updated() { - const currentIndex = this.queue.currentIndex; - - // Auto-scroll to the active track when it changes. - if ( - currentIndex >= 0 && - currentIndex !== this.lastScrolledIndex && - this.virtualizer + private handleTrackClick( + e: MouseEvent, + _track: QueueTrack, + index: number, ) { - this.lastScrolledIndex = currentIndex; - requestAnimationFrame(() => { - this.virtualizer?.scrollToIndex(currentIndex, 'center'); - }); - } - } - - private async handleAddToPlaylist() { - if (this.queue.tracks.length === 0) return; - - this.playlistPickerOpen = !this.playlistPickerOpen; - - await this.updateComplete; - - const popup = this.addToPlaylistPopup; - const btn = this.shadowRoot?.querySelector('.add-to-playlist-button'); - - if (popup && btn) { - (popup as any).anchor = btn; - (popup as any).active = this.playlistPickerOpen; + this.selection.handleItemClick( + e, + String(index), + index, + ); } - if (this.playlistPickerOpen) { - const picker = this.shadowRoot?.querySelector( - 'playlist-picker', - ) as PlaylistPicker | null; - - picker?.reset(); + private handleTrackDblClick(index: number) { + this.selection.clear(); + this.queue.playAtIndex(index); } - } - private closePlaylistPicker() { - if (!this.playlistPickerOpen) return; + private handleTrackContextMenu( + e: MouseEvent, + index: number, + ) { + e.preventDefault(); + e.stopPropagation(); - this.playlistPickerOpen = false; + this.selection.handleContextMenu(String(index)); + this.contextMenuOpen = true; - const popup = this.addToPlaylistPopup; + // Position at mouse cursor using a virtual anchor. + this.updateComplete.then(() => { + const popup = this.contextMenuPopup; - if (popup) { - (popup as any).active = false; + 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 onPlaylistActionComplete = () => { - this.closePlaylistPicker(); - }; + private onContextMenuAction(action: string) { + const indices = + this.selection.getSelectedIndices(); - private handleRemoveTrack(e: Event, position: number) { - e.stopPropagation(); - this.queue.removeFromQueue(position); - } + if (indices.length === 0) return; - private handleTrackClick(index: number) { - this.queue.playAtIndex(index); - } + switch (action) { + case 'play': + this.queue.playAtIndex(indices[0]!); + break; + case 'remove': + this.queue.removeTracksFromQueue(indices); + break; + } - private getDisplayTitle( - track: { title: string; filePath: string }, - ): string { - if (track.title) return track.title; + this.closeContextMenu(true); + } - // Fall back to filename without extension. - const parts = track.filePath.split(/[\\/]/); - const filename = parts[parts.length - 1] ?? track.filePath; + private closeContextMenu(clearSelection = false) { + if (!this.contextMenuOpen) return; - return filename.replace(/\.[^.]+$/, ''); - } + this.closePlaylistSubmenu(); + this.contextMenuOpen = false; - private handleMouseDown = (e: MouseEvent) => { - e.preventDefault(); - this.isDragging = true; - }; + if (clearSelection) { + this.selection.clear(); + } - private handleMouseMove = (e: MouseEvent) => { - if (!this.isDragging) return; + const popup = this.contextMenuPopup; - const rect = this.getBoundingClientRect(); - const newWidth = rect.right - e.clientX; - const clampedWidth = Math.min( - Math.max(newWidth, MIN_WIDTH), - MAX_WIDTH, - ); + if (popup) { + (popup as any).active = false; + } + } - this.panelWidth = clampedWidth; - this.style.setProperty('--queue-width', `${clampedWidth}px`); - }; + private async showPlaylistSubmenu() { + if (this.playlistSubmenuOpen) return; - private handleMouseUp = () => { - if (!this.isDragging) return; + this.playlistSubmenuOpen = true; - this.isDragging = false; - }; + await this.updateComplete; - private renderTrackItem = ( - track: QueueTrack, - index: number, - ) => { - const currentIndex = this.queue.currentIndex; + const submenu = this.playlistSubmenuPopup; + const trigger = + this.shadowRoot?.querySelector('.submenu-item'); - return html` -
this.handleTrackClick(index)} - > - ${index + 1} -
- - ${this.getDisplayTitle(track)} - - - ${track.artist || 'Unknown Artist'} - -
- -
- `; - }; + if (submenu && trigger) { + (submenu as any).anchor = trigger; + (submenu as any).active = true; + } - override render() { - const tracks = this.queue.tracks; + const picker = this.shadowRoot?.querySelector( + '#context-playlist-picker', + ) as PlaylistPicker | null; - return html` -
-
-
-

Queue

- -
+ picker?.reset(); + } - - ${this.playlistPickerOpen - ? html` - t.filePath)} - @playlist-action-complete=${this.onPlaylistActionComplete} - @click=${(e: Event) => e.stopPropagation()} - > - ` - : nothing} - + private closePlaylistSubmenu() { + if (!this.playlistSubmenuOpen) return; - ${tracks.length === 0 - ? html` -
- -

Queue is empty

-

- Click a track to start playing -

-
- ` - : html` - - `} -
- `; - } + this.playlistSubmenuOpen = false; + + const submenu = this.playlistSubmenuPopup; + + if (submenu) { + (submenu as any).active = false; + } + } + + /** + * Derive file paths from selected indices for + * operations that need file paths (e.g. Add to Playlist). + */ + private getSelectedFilePaths(): string[] { + const tracks = this.queue.tracks; + + return this.selection + .getSelectedIndices() + .map((i) => tracks[i]!.filePath); + } + + private onContextPlaylistActionComplete = () => { + this.closeContextMenu(true); + }; + + // ================================================================= + // Other handlers + // ================================================================= + + private handleRemoveTrack(e: Event, position: number) { + e.stopPropagation(); + this.queue.removeFromQueue(position); + } + + private getDisplayTitle(track: { + title: string; + filePath: string; + }): string { + if (track.title) return track.title; + + // Fall back to filename without extension. + const parts = track.filePath.split(/[\\/]/); + const filename = + parts[parts.length - 1] ?? track.filePath; + + return filename.replace(/\.[^.]+$/, ''); + } + + private handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + this.isDragging = true; + }; + + private handleMouseMove = (e: MouseEvent) => { + if (!this.isDragging) return; + + const rect = this.getBoundingClientRect(); + const newWidth = rect.right - e.clientX; + const clampedWidth = Math.min( + Math.max(newWidth, MIN_WIDTH), + MAX_WIDTH, + ); + + this.panelWidth = clampedWidth; + this.style.setProperty( + '--queue-width', + `${clampedWidth}px`, + ); + }; + + private handleMouseUp = () => { + if (!this.isDragging) return; + + this.isDragging = false; + }; + + private renderTrackItem = ( + track: QueueTrack, + index: number, + ) => { + const currentIndex = this.queue.currentIndex; + const active = index === currentIndex; + const selected = this.selection.isSelected( + String(index), + ); + + const classes = [ + 'track-item', + active ? 'active' : '', + selected ? 'selected' : '', + ] + .filter(Boolean) + .join(' '); + + return html` +
+ this.handleTrackClick(e, track, index)} + @dblclick=${() => + this.handleTrackDblClick(index)} + @contextmenu=${(e: MouseEvent) => + this.handleTrackContextMenu(e, index)} + > + + ${index + 1} + +
+ + ${this.getDisplayTitle(track)} + + + ${track.artist || 'Unknown Artist'} + +
+ +
+ `; + }; + + override render() { + const tracks = this.queue.tracks; + + return html` +
+
+
+

Queue

+ +
+ + + ${this.playlistPickerOpen + ? html` + t.filePath, + )} + @playlist-action-complete=${this + .onPlaylistActionComplete} + @click=${(e: Event) => + e.stopPropagation()} + > + ` + : nothing} + + + ${tracks.length === 0 + ? html` +
+ +

Queue is empty

+

+ Click a track to start + playing +

+
+ ` + : html` + + `} +
+ + + ${this.contextMenuOpen + ? html` +
+ + this.onContextMenuAction( + 'play', + )} + > + + Play + + + this.onContextMenuAction( + 'remove', + )} + > + + Remove from Queue + + + this.showPlaylistSubmenu()} + @click=${(e: Event) => { + e.stopPropagation(); + void this.showPlaylistSubmenu(); + }} + > + + Add to Playlist + + ▶ + + +
+ ` + : nothing} +
+ + + ${this.playlistSubmenuOpen && + this.selection.hasSelection + ? html` + + e.stopPropagation()} + > + ` + : nothing} + + `; + } } diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 03ffa1e..fc2b857 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -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 = 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 { - const start = Math.min(from, to); - const end = Math.max(from, to); - const paths = new Set(); - - 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` e.stopPropagation()} > diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 47b8a50..938c6c2 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -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", diff --git a/frontend/src/store/controllers/queue-controller.ts b/frontend/src/store/controllers/queue-controller.ts index 5c1b00f..9cb5afd 100644 --- a/frontend/src/store/controllers/queue-controller.ts +++ b/frontend/src/store/controllers/queue-controller.ts @@ -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); } diff --git a/frontend/src/store/queue-store.ts b/frontend/src/store/queue-store.ts index 04f8300..7b98bae 100644 --- a/frontend/src/store/queue-store.ts +++ b/frontend/src/store/queue-store.ts @@ -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); } diff --git a/frontend/src/utils/selection-controller.ts b/frontend/src/utils/selection-controller.ts new file mode 100644 index 0000000..011aecc --- /dev/null +++ b/frontend/src/utils/selection-controller.ts @@ -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 = 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 { + 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 { + const start = Math.min(from, to); + const end = Math.max(from, to); + const keys = new Set(); + + for (let i = start; i <= end; i++) { + const key = this.host.getItemKey(i); + + if (key !== undefined) { + keys.add(key); + } + } + + return keys; + } +} diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 9dadb7b..06cc8aa 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -15,4 +15,6 @@ export function GetAllPlaylistsWithTracks():Promise>; export function GetPlaylistTracks(arg1:number):Promise>; +export function RemoveTracksFromPlaylist(arg1:number,arg2:Array):Promise; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index 3d957a7..7e01a45 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -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); }