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.
|
// Queue events.
|
||||||
const (
|
const (
|
||||||
QueueChanged = "QueueChanged"
|
QueueChanged = "QueueChanged"
|
||||||
RequestNext = "RequestNext"
|
QueueIndexChanged = "QueueIndexChanged"
|
||||||
RequestPrevious = "RequestPrevious"
|
QueueModeChanged = "QueueModeChanged"
|
||||||
RequestSetQueue = "RequestSetQueue"
|
QueueTracksModified = "QueueTracksModified"
|
||||||
RequestAddToQueue = "RequestAddToQueue"
|
RequestNext = "RequestNext"
|
||||||
RequestPlayNext = "RequestPlayNext"
|
RequestPrevious = "RequestPrevious"
|
||||||
RequestRemoveFromQueue = "RequestRemoveFromQueue"
|
RequestSetQueue = "RequestSetQueue"
|
||||||
RequestToggleShuffle = "RequestToggleShuffle"
|
RequestAddToQueue = "RequestAddToQueue"
|
||||||
RequestCycleRepeat = "RequestCycleRepeat"
|
RequestPlayNext = "RequestPlayNext"
|
||||||
RequestAddTracksToQueue = "RequestAddTracksToQueue"
|
RequestRemoveFromQueue = "RequestRemoveFromQueue"
|
||||||
RequestPlayTracksNext = "RequestPlayTracksNext"
|
RequestToggleShuffle = "RequestToggleShuffle"
|
||||||
RequestPlayQueueIndex = "RequestPlayQueueIndex"
|
RequestCycleRepeat = "RequestCycleRepeat"
|
||||||
|
RequestAddTracksToQueue = "RequestAddTracksToQueue"
|
||||||
|
RequestPlayTracksNext = "RequestPlayTracksNext"
|
||||||
|
RequestPlayQueueIndex = "RequestPlayQueueIndex"
|
||||||
|
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config events.
|
// Config events.
|
||||||
|
|||||||
@@ -299,6 +299,46 @@ func (s *Service) CreatePlaylistWithTracks(
|
|||||||
return summary, nil
|
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.
|
// addSingleTrack looks up the audio file by path and inserts it into the playlist.
|
||||||
func (s *Service) addSingleTrack(
|
func (s *Service) addSingleTrack(
|
||||||
playlistID int64,
|
playlistID int64,
|
||||||
|
|||||||
+322
-43
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -38,6 +39,10 @@ const PreviousRestartThreshold = 3
|
|||||||
// per statement. We use a conservative limit for batching.
|
// per statement. We use a conservative limit for batching.
|
||||||
const maxSQLiteVars = 900
|
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.
|
// trackMeta holds the result of a batch metadata lookup.
|
||||||
type trackMeta struct {
|
type trackMeta struct {
|
||||||
AudioFileID int64
|
AudioFileID int64
|
||||||
@@ -74,6 +79,26 @@ type State struct {
|
|||||||
SourcePlaylistID int64 `json:"sourcePlaylistId"`
|
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.
|
// Queue manages an ordered list of tracks for playback.
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -127,7 +152,7 @@ func (q *Queue) OnPlaybackFinished() {
|
|||||||
// Repeat One: replay the current track.
|
// Repeat One: replay the current track.
|
||||||
if q.repeatMode == RepeatOne {
|
if q.repeatMode == RepeatOne {
|
||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,7 +167,7 @@ func (q *Queue) OnPlaybackFinished() {
|
|||||||
|
|
||||||
q.currentIndex = nextIdx
|
q.currentIndex = nextIdx
|
||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerEventHandlers sets up Wails event listeners for queue commands.
|
// registerEventHandlers sets up Wails event listeners for queue commands.
|
||||||
@@ -236,6 +261,17 @@ func (q *Queue) registerEventHandlers() {
|
|||||||
q.handlePlayQueueIndex(data...)
|
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.
|
// handleSetQueue processes the RequestSetQueue event payload.
|
||||||
@@ -337,6 +373,38 @@ func (q *Queue) handleRemoveFromQueue(data ...any) {
|
|||||||
q.RemoveTrack(int(position))
|
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.
|
// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload.
|
||||||
// Expects data[0] = []interface{} of file path strings.
|
// Expects data[0] = []interface{} of file path strings.
|
||||||
func (q *Queue) handleAddTracksToQueue(data ...any) {
|
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.
|
// SetQueue replaces the entire queue with new tracks and starts playing.
|
||||||
// It uses a two-phase approach: the start track is resolved immediately so
|
// It uses a two-phase approach: the first batch of tracks (up to
|
||||||
// playback begins without delay, then the remaining tracks are resolved in
|
// initialBatchSize) is resolved immediately so playback begins and the
|
||||||
// the background. A generation counter ensures stale background work is
|
// queue panel is populated without delay. The remaining tracks are then
|
||||||
// discarded if SetQueue is called again before it finishes.
|
// 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) {
|
func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
||||||
gen := q.setQueueGen.Add(1)
|
gen := q.setQueueGen.Add(1)
|
||||||
|
|
||||||
@@ -431,43 +500,76 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
|||||||
startIndex = 0
|
startIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: resolve only the start track so playback begins immediately.
|
// Phase 1: resolve an initial window of tracks centered on startIndex
|
||||||
startMeta := q.lookupTrackMetaBatch([]string{filePaths[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()
|
q.mu.Lock()
|
||||||
|
|
||||||
startTrackMeta, ok := startMeta[filePaths[startIndex]]
|
// Build the initial tracks slice preserving original order.
|
||||||
if !ok {
|
tracks := make([]Track, 0, len(initialPaths))
|
||||||
q.logger.Warn(
|
|
||||||
"Could not find start track in database",
|
for i, fp := range initialPaths {
|
||||||
"path", filePaths[startIndex],
|
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()
|
q.mu.Unlock()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a placeholder slice with only the start track populated.
|
q.tracks = tracks
|
||||||
// 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.sourcePlaylistID = 0
|
q.sourcePlaylistID = 0
|
||||||
q.shuffleOrder = nil
|
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.
|
// Start playing immediately.
|
||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
q.emitQueueChanged()
|
q.emitQueueChanged()
|
||||||
q.mu.Unlock()
|
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)
|
go q.resolveRemainingTracks(gen, filePaths, startIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +697,12 @@ func (q *Queue) AddTrack(filePath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.persistState()
|
q.persistState()
|
||||||
q.emitQueueChanged()
|
q.emitTracksModified(
|
||||||
|
"add",
|
||||||
|
[]Track{track},
|
||||||
|
len(q.tracks)-1,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTracks appends multiple tracks to the end of the queue.
|
// AddTracks appends multiple tracks to the end of the queue.
|
||||||
@@ -607,6 +714,9 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
wasEmpty := len(q.tracks) == 0
|
wasEmpty := len(q.tracks) == 0
|
||||||
|
insertIndex := len(q.tracks)
|
||||||
|
|
||||||
|
var newTracks []Track
|
||||||
|
|
||||||
for _, fp := range filePaths {
|
for _, fp := range filePaths {
|
||||||
m, ok := allMeta[fp]
|
m, ok := allMeta[fp]
|
||||||
@@ -628,6 +738,8 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.tracks = append(q.tracks, track)
|
q.tracks = append(q.tracks, track)
|
||||||
|
|
||||||
|
newTracks = append(newTracks, track)
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.shuffleMode {
|
if q.shuffleMode {
|
||||||
@@ -642,7 +754,12 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitQueueChanged()
|
q.emitTracksModified(
|
||||||
|
"add",
|
||||||
|
newTracks,
|
||||||
|
insertIndex,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
|
// 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.playCurrentTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitQueueChanged()
|
q.emitTracksModified(
|
||||||
|
"insert",
|
||||||
|
newTracks,
|
||||||
|
insertPos,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertNext inserts a track right after the currently playing track.
|
// InsertNext inserts a track right after the currently playing track.
|
||||||
@@ -752,7 +874,12 @@ func (q *Queue) InsertNext(filePath string) {
|
|||||||
|
|
||||||
q.persistTracks()
|
q.persistTracks()
|
||||||
q.persistState()
|
q.persistState()
|
||||||
q.emitQueueChanged()
|
q.emitTracksModified(
|
||||||
|
"insert",
|
||||||
|
[]Track{track},
|
||||||
|
insertPos,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveTrack removes a track at the given position from the queue.
|
// RemoveTrack removes a track at the given position from the queue.
|
||||||
@@ -769,6 +896,9 @@ func (q *Queue) RemoveTrack(position int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removingCurrent := q.currentIndex >= 0 &&
|
||||||
|
position == q.currentIndex
|
||||||
|
|
||||||
q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
|
q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
|
||||||
|
|
||||||
// Adjust current index if needed. A currentIndex of -1 means no track
|
// 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.persistTracks()
|
||||||
q.persistState()
|
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
|
// 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.
|
// Repeat One: replay the current track.
|
||||||
if q.repeatMode == RepeatOne {
|
if q.repeatMode == RepeatOne {
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -821,7 +1034,7 @@ func (q *Queue) Next() {
|
|||||||
|
|
||||||
q.currentIndex = nextIdx
|
q.currentIndex = nextIdx
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous goes to the previous track (or restarts current if >3s in).
|
// 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.
|
// Repeat One: replay the current track.
|
||||||
if q.repeatMode == RepeatOne {
|
if q.repeatMode == RepeatOne {
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -850,7 +1063,7 @@ func (q *Queue) Previous() {
|
|||||||
posSecs, err := q.player.CurrentPositionSeconds()
|
posSecs, err := q.player.CurrentPositionSeconds()
|
||||||
if err == nil && posSecs > PreviousRestartThreshold {
|
if err == nil && posSecs > PreviousRestartThreshold {
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -860,14 +1073,14 @@ func (q *Queue) Previous() {
|
|||||||
if prevIdx == -1 {
|
if prevIdx == -1 {
|
||||||
// At the beginning — just restart the current track.
|
// At the beginning — just restart the current track.
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
q.currentIndex = prevIdx
|
q.currentIndex = prevIdx
|
||||||
q.playOrLoadCurrentTrack(wasPlaying)
|
q.playOrLoadCurrentTrack(wasPlaying)
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayFromStart restarts playback from the beginning of the queue.
|
// PlayFromStart restarts playback from the beginning of the queue.
|
||||||
@@ -894,7 +1107,7 @@ func (q *Queue) PlayFromStart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayIndex jumps to and plays the track at the given index.
|
// 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.currentIndex = index
|
||||||
q.playCurrentTrack()
|
q.playCurrentTrack()
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleShuffle toggles shuffle mode on/off.
|
// ToggleShuffle toggles shuffle mode on/off.
|
||||||
@@ -934,7 +1147,7 @@ func (q *Queue) ToggleShuffle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.persistState()
|
q.persistState()
|
||||||
q.emitQueueChanged()
|
q.emitModeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CycleRepeat cycles through repeat modes: off -> all -> one -> off.
|
// CycleRepeat cycles through repeat modes: off -> all -> one -> off.
|
||||||
@@ -952,7 +1165,7 @@ func (q *Queue) CycleRepeat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.persistState()
|
q.persistState()
|
||||||
q.emitQueueChanged()
|
q.emitModeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetState returns the current queue state for the frontend.
|
// 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.
|
// onQueueExhausted is called when there are no more tracks to play.
|
||||||
// It unloads the current track, resets the index to -1 (no current track),
|
// It unloads the current track, resets the index to -1 (no current track),
|
||||||
// and notifies the frontend.
|
// and notifies the frontend.
|
||||||
@@ -1276,7 +1502,7 @@ func (q *Queue) onQueueExhausted() {
|
|||||||
q.player.UnloadTrack()
|
q.player.UnloadTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitQueueChanged()
|
q.emitIndexChanged()
|
||||||
q.persistState()
|
q.persistState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1544,6 +1770,59 @@ func (q *Queue) emitQueueChanged() {
|
|||||||
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
|
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.
|
// Sentinel errors.
|
||||||
var (
|
var (
|
||||||
ErrEmptyQueue = errors.New("queue is empty")
|
ErrEmptyQueue = errors.New("queue is empty")
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
import { LitElement, html, css } from 'lit';
|
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 '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
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')
|
@customElement('player-controls')
|
||||||
export class PlayerControls extends LitElement {
|
export class PlayerControls extends LitElement {
|
||||||
private player = new PlayerController(this);
|
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`
|
static override styles = css`
|
||||||
#player-control-buttons {
|
#player-control-buttons {
|
||||||
@@ -59,19 +88,19 @@ export class PlayerControls extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private handleNextClick = () => {
|
private handleNextClick = () => {
|
||||||
this.queue.next();
|
queueStore.next();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handlePreviousClick = () => {
|
private handlePreviousClick = () => {
|
||||||
this.queue.previous();
|
queueStore.previous();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleShuffleClick = () => {
|
private handleShuffleClick = () => {
|
||||||
this.queue.toggleShuffle();
|
queueStore.toggleShuffle();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleRepeatClick = () => {
|
private handleRepeatClick = () => {
|
||||||
this.queue.cycleRepeat();
|
queueStore.cycleRepeat();
|
||||||
};
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -80,8 +109,8 @@ export class PlayerControls extends LitElement {
|
|||||||
? this.handlePauseClick
|
? this.handlePauseClick
|
||||||
: this.handlePlayClick;
|
: this.handlePlayClick;
|
||||||
|
|
||||||
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
|
const shuffleClass = this.shuffleMode ? 'active' : '';
|
||||||
const repeatMode = this.queue.repeatMode;
|
const repeatMode = this.repeatMode;
|
||||||
const repeatClasses = [
|
const repeatClasses = [
|
||||||
repeatMode !== 'off' ? 'active' : '',
|
repeatMode !== 'off' ? 'active' : '',
|
||||||
repeatMode === 'one' ? 'repeat-one' : '',
|
repeatMode === 'one' ? 'repeat-one' : '',
|
||||||
|
|||||||
@@ -358,10 +358,11 @@ export class CoverGrid extends LitElement {
|
|||||||
typeof setTimeout
|
typeof setTimeout
|
||||||
> | null = null;
|
> | null = null;
|
||||||
private pendingFocus: {
|
private pendingFocus: {
|
||||||
row: number;
|
albumIndex: number;
|
||||||
viewportOffset: number;
|
viewportOffset: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private currentColumnCount = 0;
|
private currentColumnCount = 0;
|
||||||
|
private isResizing = false;
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
* Lifecycle
|
* Lifecycle
|
||||||
@@ -510,6 +511,12 @@ export class CoverGrid extends LitElement {
|
|||||||
private onVisibilityChanged = (
|
private onVisibilityChanged = (
|
||||||
e: VisibilityChangedEvent,
|
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) {
|
if (this.scrollDebounceTimer !== null) {
|
||||||
clearTimeout(this.scrollDebounceTimer);
|
clearTimeout(this.scrollDebounceTimer);
|
||||||
}
|
}
|
||||||
@@ -534,10 +541,11 @@ export class CoverGrid extends LitElement {
|
|||||||
* open/close, window resize) the grid reflows and
|
* open/close, window resize) the grid reflows and
|
||||||
* the pixel scroll position becomes stale.
|
* the pixel scroll position becomes stale.
|
||||||
*
|
*
|
||||||
* We compute a fractional album index at a focus
|
* We identify the album at the viewport center
|
||||||
* point before the resize, then after the reflow we
|
* before the resize, then after the reflow we
|
||||||
* place that same index back at the same viewport
|
* place that same album back at the same viewport
|
||||||
* offset.
|
* offset. Integer album indices ensure zero
|
||||||
|
* scroll creep across repeated open/close cycles.
|
||||||
*
|
*
|
||||||
* If a dropdown is open the expanded album is the
|
* If a dropdown is open the expanded album is the
|
||||||
* focus; otherwise the album at the viewport center
|
* focus; otherwise the album at the viewport center
|
||||||
@@ -569,6 +577,7 @@ export class CoverGrid extends LitElement {
|
|||||||
const pending = this.pendingFocus;
|
const pending = this.pendingFocus;
|
||||||
|
|
||||||
this.pendingFocus = null;
|
this.pendingFocus = null;
|
||||||
|
this.isResizing = false;
|
||||||
|
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
|
|
||||||
@@ -585,10 +594,15 @@ export class CoverGrid extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No dropdown — restore the
|
// Derive the album's row under the new
|
||||||
// center-of-viewport position.
|
// 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 =
|
const newY =
|
||||||
GRID_PADDING + pending.row * rowStep;
|
GRID_PADDING + newRow * rowStep;
|
||||||
|
|
||||||
container.scrollTop =
|
container.scrollTop =
|
||||||
newY - pending.viewportOffset;
|
newY - pending.viewportOffset;
|
||||||
@@ -599,6 +613,8 @@ export class CoverGrid extends LitElement {
|
|||||||
// Capture on the first event using
|
// Capture on the first event using
|
||||||
// the pre-resize column count.
|
// the pre-resize column count.
|
||||||
if (this.pendingFocus === null) {
|
if (this.pendingFocus === null) {
|
||||||
|
this.isResizing = true;
|
||||||
|
|
||||||
this.captureFocusPoint(
|
this.captureFocusPoint(
|
||||||
container,
|
container,
|
||||||
rowStep,
|
rowStep,
|
||||||
@@ -655,12 +671,18 @@ export class CoverGrid extends LitElement {
|
|||||||
* If a dropdown is open, the expanded album is the
|
* If a dropdown is open, the expanded album is the
|
||||||
* focus and its current viewport offset is preserved.
|
* focus and its current viewport offset is preserved.
|
||||||
* Otherwise the album at the viewport center is used.
|
* 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(
|
private captureFocusPoint(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
rowStep: number,
|
rowStep: number,
|
||||||
) {
|
) {
|
||||||
const { GRID_PADDING } = CoverGrid;
|
const { GRID_PADDING } = CoverGrid;
|
||||||
|
const cols = this.currentColumnCount;
|
||||||
|
|
||||||
// Prefer the expanded album as focus.
|
// Prefer the expanded album as focus.
|
||||||
if (this.expandedAlbumId !== null) {
|
if (this.expandedAlbumId !== null) {
|
||||||
@@ -670,13 +692,13 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const albumRow = Math.floor(
|
const albumRow = Math.floor(
|
||||||
idx / this.currentColumnCount,
|
idx / cols,
|
||||||
);
|
);
|
||||||
const albumY =
|
const albumY =
|
||||||
GRID_PADDING + albumRow * rowStep;
|
GRID_PADDING + albumRow * rowStep;
|
||||||
|
|
||||||
this.pendingFocus = {
|
this.pendingFocus = {
|
||||||
row: albumRow,
|
albumIndex: idx,
|
||||||
viewportOffset:
|
viewportOffset:
|
||||||
albumY - container.scrollTop,
|
albumY - container.scrollTop,
|
||||||
};
|
};
|
||||||
@@ -685,17 +707,30 @@ export class CoverGrid extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to the viewport center.
|
// Fall back to the album whose row contains
|
||||||
const halfViewport =
|
// the viewport center.
|
||||||
container.clientHeight / 2;
|
|
||||||
const centerY =
|
const centerY =
|
||||||
container.scrollTop + halfViewport;
|
container.scrollTop +
|
||||||
const row =
|
container.clientHeight / 2;
|
||||||
(centerY - GRID_PADDING) / rowStep;
|
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 = {
|
this.pendingFocus = {
|
||||||
row,
|
albumIndex,
|
||||||
viewportOffset: halfViewport,
|
viewportOffset:
|
||||||
|
albumY - container.scrollTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
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/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 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 { PlaylistController } from '@store/controllers/playlist-controller';
|
||||||
import '@components/track-info/track-info';
|
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;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
@@ -18,16 +28,131 @@ interface PlaylistEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@customElement('playlist-view')
|
@customElement('playlist-view')
|
||||||
export class PlaylistView extends LitElement {
|
export class PlaylistView
|
||||||
private queue = new QueueController(this);
|
extends LitElement
|
||||||
|
implements SelectionHost
|
||||||
|
{
|
||||||
|
private player = new PlayerController(this);
|
||||||
private playlistCtrl = new PlaylistController(this);
|
private playlistCtrl = new PlaylistController(this);
|
||||||
private scrollDebounceTimer: ReturnType<typeof setTimeout> | null =
|
private selection = new SelectionController(this);
|
||||||
null;
|
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 entries: PlaylistEntry[] = [];
|
||||||
@state() private loading = true;
|
@state() private loading = true;
|
||||||
@state() private creating = false;
|
@state() private creating = false;
|
||||||
@state() private newPlaylistName = '';
|
@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`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
@@ -139,7 +264,8 @@ export class PlaylistView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist-item {
|
.playlist-item {
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid
|
||||||
|
rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-header {
|
.playlist-header {
|
||||||
@@ -219,7 +345,27 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
.track-item {
|
.track-item {
|
||||||
padding: 6px 0;
|
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 {
|
.track-item:last-child {
|
||||||
@@ -258,11 +404,60 @@ export class PlaylistView extends LitElement {
|
|||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin: 4px 0;
|
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() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadPlaylists();
|
this.loadPlaylists();
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.closeContextMenuHandler,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
this.closeContextMenuHandler,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.clearSelectionHandler,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
@@ -272,6 +467,19 @@ export class PlaylistView extends LitElement {
|
|||||||
clearTimeout(this.scrollDebounceTimer);
|
clearTimeout(this.scrollDebounceTimer);
|
||||||
this.scrollDebounceTimer = null;
|
this.scrollDebounceTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.removeEventListener(
|
||||||
|
'click',
|
||||||
|
this.closeContextMenuHandler,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
this.closeContextMenuHandler,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'click',
|
||||||
|
this.clearSelectionHandler,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get scrollContainer(): HTMLElement | null {
|
private get scrollContainer(): HTMLElement | null {
|
||||||
@@ -318,7 +526,10 @@ export class PlaylistView extends LitElement {
|
|||||||
tracks: p.Tracks ?? [],
|
tracks: p.Tracks ?? [],
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load playlists:', err);
|
console.error(
|
||||||
|
'Failed to load playlists:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
this.entries = [];
|
this.entries = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -333,6 +544,15 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
if (!entry) return;
|
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) =>
|
this.entries = this.entries.map((e, i) =>
|
||||||
i === index
|
i === index
|
||||||
? { ...e, expanded: !e.expanded }
|
? { ...e, expanded: !e.expanded }
|
||||||
@@ -345,10 +565,211 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
if (!entry || entry.tracks.length === 0) return;
|
if (!entry || entry.tracks.length === 0) return;
|
||||||
|
|
||||||
const filePaths = entry.tracks.map((t) => t.FilePath);
|
const filePaths = entry.tracks.map(
|
||||||
this.queue.setQueue(filePaths, 0);
|
(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 = () => {
|
private handleNewPlaylistClick = () => {
|
||||||
this.creating = true;
|
this.creating = true;
|
||||||
this.newPlaylistName = '';
|
this.newPlaylistName = '';
|
||||||
@@ -379,7 +800,10 @@ export class PlaylistView extends LitElement {
|
|||||||
this.playlistCtrl.invalidate();
|
this.playlistCtrl.invalidate();
|
||||||
await this.loadPlaylists();
|
await this.loadPlaylists();
|
||||||
} catch (err) {
|
} 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() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -409,17 +837,120 @@ export class PlaylistView extends LitElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.creating ? this.renderCreateForm() : nothing}
|
${this.creating
|
||||||
|
? this.renderCreateForm()
|
||||||
|
: nothing}
|
||||||
${this.loading
|
${this.loading
|
||||||
? html`<div class="loading">
|
? html`<div class="loading">
|
||||||
Loading playlists...
|
Loading playlists...
|
||||||
</div>`
|
</div>`
|
||||||
: this.renderPlaylistList()}
|
: 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() {
|
private renderCreateForm() {
|
||||||
const canCreate = this.newPlaylistName.trim().length > 0;
|
const canCreate =
|
||||||
|
this.newPlaylistName.trim().length > 0;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
@@ -430,7 +961,9 @@ export class PlaylistView extends LitElement {
|
|||||||
@input=${this.handleInputChange}
|
@input=${this.handleInputChange}
|
||||||
@keydown=${this.handleInputKeydown}
|
@keydown=${this.handleInputKeydown}
|
||||||
/>
|
/>
|
||||||
<button @click=${this.handleCancelCreate}>
|
<button
|
||||||
|
@click=${this.handleCancelCreate}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -458,7 +991,10 @@ export class PlaylistView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ul class="playlist-list" @scroll=${this.onScroll}>
|
<ul
|
||||||
|
class="playlist-list"
|
||||||
|
@scroll=${this.onScroll}
|
||||||
|
>
|
||||||
${this.entries.map((entry, i) =>
|
${this.entries.map((entry, i) =>
|
||||||
this.renderPlaylistItem(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 trackCount = entry.tracks.length;
|
||||||
const countLabel =
|
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
||||||
`${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li class="playlist-item">
|
<li class="playlist-item">
|
||||||
<div
|
<div
|
||||||
class="playlist-header"
|
class="playlist-header"
|
||||||
@click=${() => this.handleToggle(index)}
|
@click=${() =>
|
||||||
|
this.handleToggle(index)}
|
||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
class="chevron ${entry.expanded
|
class="chevron ${entry.expanded
|
||||||
@@ -495,7 +1034,10 @@ export class PlaylistView extends LitElement {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${entry.expanded
|
${entry.expanded
|
||||||
? this.renderPlaylistBody(entry, index)
|
? this.renderPlaylistBody(
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
)
|
||||||
: nothing}
|
: nothing}
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
@@ -503,7 +1045,7 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
private renderPlaylistBody(
|
private renderPlaylistBody(
|
||||||
entry: PlaylistEntry,
|
entry: PlaylistEntry,
|
||||||
index: number,
|
playlistIndex: number,
|
||||||
) {
|
) {
|
||||||
if (entry.tracks.length === 0) {
|
if (entry.tracks.length === 0) {
|
||||||
return html`
|
return html`
|
||||||
@@ -522,7 +1064,9 @@ export class PlaylistView extends LitElement {
|
|||||||
class="play-all-button"
|
class="play-all-button"
|
||||||
@click=${(e: Event) => {
|
@click=${(e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.handlePlayAll(index);
|
this.handlePlayAll(
|
||||||
|
playlistIndex,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<wa-icon name="play"></wa-icon>
|
<wa-icon name="play"></wa-icon>
|
||||||
@@ -530,16 +1074,60 @@ export class PlaylistView extends LitElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${entry.tracks.map(
|
${entry.tracks.map(
|
||||||
(track) => html`
|
(track, trackIndex) => {
|
||||||
<div class="track-item">
|
const active =
|
||||||
<track-info
|
this.isActiveTrack(track);
|
||||||
.trackTitle=${track.Title}
|
const selected =
|
||||||
.artist=${track.Artist}
|
this.activePlaylistIndex ===
|
||||||
.duration=${track.Duration}
|
playlistIndex &&
|
||||||
.filePath=${track.FilePath}
|
this.selection.isSelected(
|
||||||
></track-info>
|
String(trackIndex),
|
||||||
</div>
|
);
|
||||||
`,
|
|
||||||
|
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>
|
</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 { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query } from 'lit/decorators.js';
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
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 { 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 { LibraryController } from '@store/controllers/library-controller';
|
||||||
import '@lit-labs/virtualizer';
|
import '@lit-labs/virtualizer';
|
||||||
import type {
|
import type {
|
||||||
@@ -23,17 +25,14 @@ const DEFAULT_DURATION_WIDTH = 80;
|
|||||||
const COLUMN_COUNT = 3;
|
const COLUMN_COUNT = 3;
|
||||||
|
|
||||||
@customElement('track-list')
|
@customElement('track-list')
|
||||||
export class TrackList extends LitElement {
|
export class TrackList extends LitElement implements SelectionHost {
|
||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
private queue = new QueueController(this);
|
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
|
private selection = new SelectionController(this);
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private tracks: library.Track[] = [];
|
private tracks: library.Track[] = [];
|
||||||
|
|
||||||
@state()
|
|
||||||
private selectedTracks: Set<string> = new Set();
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private contextMenuOpen = false;
|
private contextMenuOpen = false;
|
||||||
|
|
||||||
@@ -49,11 +48,24 @@ export class TrackList extends LitElement {
|
|||||||
@query('lit-virtualizer')
|
@query('lit-virtualizer')
|
||||||
private virtualizer!: LitVirtualizer;
|
private virtualizer!: LitVirtualizer;
|
||||||
|
|
||||||
private lastSelectedIndex: number | null = null;
|
|
||||||
private lastActiveTrackPath: string | null = null;
|
private lastActiveTrackPath: string | null = null;
|
||||||
|
|
||||||
private closeHandler = () => this.closeContextMenu();
|
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()
|
@state()
|
||||||
private columnWidths: number[] = [];
|
private columnWidths: number[] = [];
|
||||||
|
|
||||||
@@ -64,6 +76,22 @@ export class TrackList extends LitElement {
|
|||||||
private flowLayout = flow();
|
private flowLayout = flow();
|
||||||
private hasRestoredScroll = false;
|
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 {
|
private get gridTemplateColumns(): string {
|
||||||
if (this.columnWidths.length === 0) {
|
if (this.columnWidths.length === 0) {
|
||||||
return '1fr 1fr 80px';
|
return '1fr 1fr 80px';
|
||||||
@@ -361,6 +389,7 @@ export class TrackList extends LitElement {
|
|||||||
this.loadTracks();
|
this.loadTracks();
|
||||||
document.addEventListener('click', this.closeHandler);
|
document.addEventListener('click', this.closeHandler);
|
||||||
document.addEventListener('contextmenu', this.closeHandler);
|
document.addEventListener('contextmenu', this.closeHandler);
|
||||||
|
document.addEventListener('click', this.clearSelectionHandler);
|
||||||
document.addEventListener('mousemove', this.onColResizeMove);
|
document.addEventListener('mousemove', this.onColResizeMove);
|
||||||
document.addEventListener('mouseup', this.onColResizeEnd);
|
document.addEventListener('mouseup', this.onColResizeEnd);
|
||||||
|
|
||||||
@@ -380,6 +409,7 @@ export class TrackList extends LitElement {
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
document.removeEventListener('click', this.closeHandler);
|
document.removeEventListener('click', this.closeHandler);
|
||||||
document.removeEventListener('contextmenu', this.closeHandler);
|
document.removeEventListener('contextmenu', this.closeHandler);
|
||||||
|
document.removeEventListener('click', this.clearSelectionHandler);
|
||||||
document.removeEventListener('mousemove', this.onColResizeMove);
|
document.removeEventListener('mousemove', this.onColResizeMove);
|
||||||
document.removeEventListener('mouseup', this.onColResizeEnd);
|
document.removeEventListener('mouseup', this.onColResizeEnd);
|
||||||
|
|
||||||
@@ -399,10 +429,6 @@ export class TrackList extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changed.has('selectedTracks')) {
|
|
||||||
this.virtualizer?.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPath =
|
const currentPath =
|
||||||
this.player.currentTrack?.filePath ?? null;
|
this.player.currentTrack?.filePath ?? null;
|
||||||
|
|
||||||
@@ -455,8 +481,7 @@ export class TrackList extends LitElement {
|
|||||||
try {
|
try {
|
||||||
const tracks = await this.libraryCtrl.getTracks();
|
const tracks = await this.libraryCtrl.getTracks();
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.selectedTracks = new Set();
|
this.selection.clear();
|
||||||
this.lastSelectedIndex = null;
|
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
|
|
||||||
if (this.isConnected && this.virtualizer) {
|
if (this.isConnected && this.virtualizer) {
|
||||||
@@ -494,94 +519,24 @@ export class TrackList extends LitElement {
|
|||||||
this.libraryCtrl.setScrollPosition('tracks', first);
|
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(
|
private onTrackRowClick(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
track: library.Track,
|
track: library.Track,
|
||||||
index: number,
|
index: number,
|
||||||
) {
|
) {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey;
|
this.selection.handleItemClick(e, track.FilePath, index);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTrackRowDblClick(track: library.Track) {
|
private onTrackRowDblClick(track: library.Track) {
|
||||||
this.selectedTracks = new Set();
|
this.selection.clear();
|
||||||
this.queue.setQueue([track.FilePath], 0);
|
queueStore.setQueue([track.FilePath], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!this.selectedTracks.has(track.FilePath)) {
|
this.selection.handleContextMenu(track.FilePath);
|
||||||
this.selectedTracks = new Set([track.FilePath]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.contextMenuOpen = true;
|
this.contextMenuOpen = true;
|
||||||
|
|
||||||
// Position the popup at the mouse cursor using a virtual anchor.
|
// Position the popup at the mouse cursor using a virtual anchor.
|
||||||
@@ -609,19 +564,19 @@ export class TrackList extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onContextMenuAction(action: string) {
|
private onContextMenuAction(action: string) {
|
||||||
const filePaths = this.getSelectedFilePaths();
|
const filePaths = this.selection.getSelectedKeysOrdered();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'play':
|
case 'play':
|
||||||
this.queue.setQueue(filePaths, 0);
|
queueStore.setQueue(filePaths, 0);
|
||||||
break;
|
break;
|
||||||
case 'add-to-queue':
|
case 'add-to-queue':
|
||||||
this.queue.addTracksToQueue(filePaths);
|
queueStore.addTracksToQueue(filePaths);
|
||||||
break;
|
break;
|
||||||
case 'play-next':
|
case 'play-next':
|
||||||
this.queue.playTracksNext(filePaths);
|
queueStore.playTracksNext(filePaths);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +590,7 @@ export class TrackList extends LitElement {
|
|||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
|
|
||||||
if (clearSelection) {
|
if (clearSelection) {
|
||||||
this.selectedTracks = new Set();
|
this.selection.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
const popup = this.contextMenuPopup;
|
const popup = this.contextMenuPopup;
|
||||||
@@ -696,7 +651,7 @@ export class TrackList extends LitElement {
|
|||||||
index: number,
|
index: number,
|
||||||
): unknown => {
|
): unknown => {
|
||||||
const active = this.isActiveTrack(track);
|
const active = this.isActiveTrack(track);
|
||||||
const selected = this.selectedTracks.has(track.FilePath);
|
const selected = this.selection.isSelected(track.FilePath);
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
'track-row',
|
'track-row',
|
||||||
@@ -803,10 +758,10 @@ export class TrackList extends LitElement {
|
|||||||
placement="right-start"
|
placement="right-start"
|
||||||
.active=${this.playlistSubmenuOpen}
|
.active=${this.playlistSubmenuOpen}
|
||||||
>
|
>
|
||||||
${this.playlistSubmenuOpen && this.selectedTracks.size > 0
|
${this.playlistSubmenuOpen && this.selection.hasSelection
|
||||||
? html`
|
? html`
|
||||||
<playlist-picker
|
<playlist-picker
|
||||||
.filePaths=${this.getSelectedFilePaths()}
|
.filePaths=${this.selection.getSelectedKeysOrdered()}
|
||||||
@playlist-action-complete=${this.onPlaylistActionComplete}
|
@playlist-action-complete=${this.onPlaylistActionComplete}
|
||||||
@click=${(e: Event) => e.stopPropagation()}
|
@click=${(e: Event) => e.stopPropagation()}
|
||||||
></playlist-picker>
|
></playlist-picker>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const Events = {
|
|||||||
|
|
||||||
// Queue events
|
// Queue events
|
||||||
QueueChanged: "QueueChanged",
|
QueueChanged: "QueueChanged",
|
||||||
|
QueueIndexChanged: "QueueIndexChanged",
|
||||||
|
QueueModeChanged: "QueueModeChanged",
|
||||||
|
QueueTracksModified: "QueueTracksModified",
|
||||||
RequestNext: "RequestNext",
|
RequestNext: "RequestNext",
|
||||||
RequestPrevious: "RequestPrevious",
|
RequestPrevious: "RequestPrevious",
|
||||||
RequestSetQueue: "RequestSetQueue",
|
RequestSetQueue: "RequestSetQueue",
|
||||||
@@ -33,6 +36,7 @@ export const Events = {
|
|||||||
RequestAddTracksToQueue: "RequestAddTracksToQueue",
|
RequestAddTracksToQueue: "RequestAddTracksToQueue",
|
||||||
RequestPlayTracksNext: "RequestPlayTracksNext",
|
RequestPlayTracksNext: "RequestPlayTracksNext",
|
||||||
RequestPlayQueueIndex: "RequestPlayQueueIndex",
|
RequestPlayQueueIndex: "RequestPlayQueueIndex",
|
||||||
|
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
LibraryScanComplete: "LibraryScanComplete",
|
LibraryScanComplete: "LibraryScanComplete",
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export class QueueController implements ReactiveController {
|
|||||||
queueStore.removeFromQueue(position);
|
queueStore.removeFromQueue(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTracksFromQueue(positions: number[]): void {
|
||||||
|
queueStore.removeTracksFromQueue(positions);
|
||||||
|
}
|
||||||
|
|
||||||
addTracksToQueue(filePaths: string[]): void {
|
addTracksToQueue(filePaths: string[]): void {
|
||||||
queueStore.addTracksToQueue(filePaths);
|
queueStore.addTracksToQueue(filePaths);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ export interface QueueState {
|
|||||||
sourcePlaylistId: number;
|
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;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
class QueueStore {
|
class QueueStore {
|
||||||
@@ -44,6 +62,7 @@ class QueueStore {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
private initializeEventListeners(): void {
|
private initializeEventListeners(): void {
|
||||||
|
// Full-state sync (startup, SetQueue).
|
||||||
EventsOn(Events.QueueChanged, (queueState: QueueState) => {
|
EventsOn(Events.QueueChanged, (queueState: QueueState) => {
|
||||||
this.state = {
|
this.state = {
|
||||||
tracks: queueState.tracks ?? [],
|
tracks: queueState.tracks ?? [],
|
||||||
@@ -54,6 +73,68 @@ class QueueStore {
|
|||||||
};
|
};
|
||||||
this.notify();
|
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);
|
EventsEmit(Events.RequestRemoveFromQueue, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTracksFromQueue(positions: number[]): void {
|
||||||
|
EventsEmit(Events.RequestRemoveTracksFromQueue, positions);
|
||||||
|
}
|
||||||
|
|
||||||
addTracksToQueue(filePaths: string[]): void {
|
addTracksToQueue(filePaths: string[]): void {
|
||||||
EventsEmit(Events.RequestAddTracksToQueue, filePaths);
|
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 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>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function GetPlaylistTracks(arg1) {
|
|||||||
return window['go']['playlist']['Service']['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) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['playlist']['Service']['SetContext'](arg1);
|
return window['go']['playlist']['Service']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user