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:
+16
-12
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
▶
|
||||
</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user