Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

943 lines
22 KiB
Go

// Package playlist manages an ordered track list with shuffle and repeat support.
package playlist
import (
"math/rand"
"net/url"
pathpkg "path"
"path/filepath"
"slices"
"strings"
"sync"
)
// RepeatMode controls playlist repeat behavior.
type RepeatMode int
const (
RepeatOff RepeatMode = iota
RepeatAll
RepeatOne
)
func (r RepeatMode) String() string {
switch r {
case RepeatAll:
return "All"
case RepeatOne:
return "One"
default:
return "Off"
}
}
// Track represents a single audio file or HTTP stream.
type Track struct {
Path string
Title string
Artist string
Album string
Genre string
Year int
TrackNumber int
Stream bool // true for HTTP/HTTPS URLs
Realtime bool // true for real-time/live streams (e.g. radio)
Feed bool // true for RSS/podcast feed URLs (resolved before playback)
DurationSecs int // known duration in seconds (0 = unknown)
Bookmark bool // user-bookmarked track
Unplayable bool // true when the track is known not playable in the current playback context
EmbeddedLyrics string // embedded lyrics from local file tags, when present
AlbumArtURL string // file:// URL for cached embedded album art, when present
// ProviderMeta holds provider-specific key-value pairs.
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
ProviderMeta map[string]string
}
// Meta returns the value for a provider-specific metadata key, or "" if unset.
func (t Track) Meta(key string) string {
if t.ProviderMeta == nil {
return ""
}
return t.ProviderMeta[key]
}
// TotalDurationSecs sums DurationSecs across a slice of tracks, skipping
// entries with unknown duration (zero).
func TotalDurationSecs(tracks []Track) int {
total := 0
for _, t := range tracks {
if t.DurationSecs > 0 {
total += t.DurationSecs
}
}
return total
}
// IsURL reports whether path is an HTTP or HTTPS URL, or a yt-dlp search protocol string.
func IsURL(path string) bool {
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") ||
IsYTSearch(path)
}
// IsYTSearch reports whether path is a yt-dlp search expression
// (ytsearch:, ytsearchN:, scsearch:, scsearchN:).
func IsYTSearch(path string) bool {
return matchSearchPrefix(path, "ytsearch") || matchSearchPrefix(path, "scsearch")
}
func matchSearchPrefix(path, name string) bool {
if !strings.HasPrefix(path, name) {
return false
}
rest := path[len(name):]
colon := strings.IndexByte(rest, ':')
if colon < 0 {
return false
}
for _, c := range rest[:colon] {
if c < '0' || c > '9' {
return false
}
}
return true
}
// IsM3U reports whether the path points to an M3U playlist file (URL or local).
func IsM3U(path string) bool {
if IsURL(path) {
u, err := url.Parse(path)
if err != nil {
return false
}
ext := strings.ToLower(pathpkg.Ext(u.Path))
return ext == ".m3u" || ext == ".m3u8"
}
ext := strings.ToLower(filepath.Ext(path))
return ext == ".m3u" || ext == ".m3u8"
}
// IsLocalM3U reports whether the path is a local (non-URL) M3U file.
func IsLocalM3U(path string) bool {
return !IsURL(path) && IsM3U(path)
}
// IsPLS reports whether the path points to a PLS playlist file (URL or local).
func IsPLS(path string) bool {
if IsURL(path) {
u, err := url.Parse(path)
if err != nil {
return false
}
return strings.ToLower(pathpkg.Ext(u.Path)) == ".pls"
}
return strings.ToLower(filepath.Ext(path)) == ".pls"
}
// IsLocalPLS reports whether the path is a local (non-URL) PLS file.
func IsLocalPLS(path string) bool {
return !IsURL(path) && IsPLS(path)
}
// IsYouTubeURL reports whether the URL points to YouTube (youtube.com or youtu.be).
// YouTube Music (music.youtube.com) is excluded — use IsYouTubeMusicURL for that.
func IsYouTubeURL(path string) bool {
if !IsURL(path) {
return false
}
// ytsearch: protocols are handled by yt-dlp, not the native YouTube client.
if IsYTSearch(path) {
return false
}
u, err := url.Parse(path)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
host = strings.TrimPrefix(host, "www.")
host = strings.TrimPrefix(host, "m.")
switch host {
case "youtube.com", "youtu.be":
return true
}
return false
}
// IsYouTubeMusicURL reports whether the URL points to YouTube Music (music.youtube.com).
// These URLs require yt-dlp rather than the native YouTube API client.
func IsYouTubeMusicURL(path string) bool {
if !IsURL(path) {
return false
}
u, err := url.Parse(path)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
host = strings.TrimPrefix(host, "www.")
host = strings.TrimPrefix(host, "m.")
return host == "music.youtube.com"
}
// IsYTDL reports whether the URL points to a site supported by yt-dlp
// (YouTube, SoundCloud, Bandcamp, ytsearch: protocol, etc.).
func IsYTDL(path string) bool {
if !IsURL(path) {
return false
}
// YouTube and YouTube Music URLs are handled by yt-dlp for playback.
if IsYouTubeURL(path) || IsYouTubeMusicURL(path) {
return true
}
if IsYTSearch(path) {
return true
}
u, err := url.Parse(path)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
host = strings.TrimPrefix(host, "www.")
host = strings.TrimPrefix(host, "m.")
switch host {
case "soundcloud.com",
"bandcamp.com",
"music.163.com",
"bilibili.com",
"b23.tv":
return true
}
// Bilibili subdomains (e.g. space.bilibili.com)
if strings.HasSuffix(host, ".bilibili.com") {
return true
}
// Bandcamp artist subdomains (e.g. artist.bandcamp.com)
if strings.HasSuffix(host, ".bandcamp.com") {
return true
}
return false
}
// IsXiaoyuzhouEpisode reports whether the URL points to a Xiaoyuzhou episode page.
func IsXiaoyuzhouEpisode(path string) bool {
if !IsURL(path) {
return false
}
u, err := url.Parse(path)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
host = strings.TrimPrefix(host, "www.")
host = strings.TrimPrefix(host, "m.")
if host != "xiaoyuzhoufm.com" {
return false
}
return strings.HasPrefix(strings.ToLower(u.Path), "/episode/")
}
// IsFeed reports whether the URL points to a podcast RSS/XML feed.
func IsFeed(path string) bool {
if !IsURL(path) {
return false
}
u, err := url.Parse(path)
if err != nil {
return false
}
ext := strings.ToLower(pathpkg.Ext(u.Path))
return ext == ".xml" || ext == ".rss" || ext == ".atom"
}
// TrackFromPath creates a Track by parsing the filename or URL.
// For local files, embedded tags (ID3v2, Vorbis, MP4) are tried first,
// falling back to "Artist - Title" filename parsing.
func TrackFromPath(path string) Track {
if IsURL(path) {
return trackFromURL(path)
}
return readTags(path)
}
// trackFromURL creates a Track from an HTTP/HTTPS URL, extracting a clean
// display title from the URL path (ignoring query parameters).
func trackFromURL(rawURL string) Track {
t := Track{Path: rawURL, Stream: true}
u, err := url.Parse(rawURL)
if err != nil {
t.Title = rawURL
return t
}
// Extract filename from URL path using slash semantics, not OS-specific
// filepath rules. URL paths always use '/'.
base := pathpkg.Base(u.Path)
if base != "" && base != "." && base != "/" {
name := strings.TrimSuffix(base, pathpkg.Ext(base))
if name != "" && name != "stream" && name != "rest" {
t.Title = name
return t
}
}
// Fallback: use hostname
t.Title = u.Hostname()
return t
}
// IsLive reports whether the track is a live stream (e.g. Icecast radio)
func (t Track) IsLive() bool {
return t.Realtime
}
// DisplayName returns a formatted display string for the track.
func (t Track) DisplayName() string {
if t.Artist != "" {
return t.Artist + " - " + t.Title
}
return t.Title
}
// Playlist manages an ordered list of tracks with shuffle and repeat support.
// All exported methods are safe for concurrent use: the Bubbletea UI loop
// mutates the playlist while Lua plugin goroutines read state through it.
type Playlist struct {
mu sync.Mutex
tracks []Track
order []int // indices into tracks, shuffled or sequential
pos int // current position in order
shuffle bool
repeat RepeatMode
queue []int // track indices queued to play next
queuedIdx int // track index currently playing from queue, -1 if none
}
// New creates an empty Playlist.
func New() *Playlist {
return &Playlist{queuedIdx: -1}
}
// Replace clears the playlist and loads the given tracks, resetting
// position, queue, and shuffle order.
func (p *Playlist) Replace(tracks []Track) {
p.mu.Lock()
defer p.mu.Unlock()
p.tracks = tracks
p.order = make([]int, len(tracks))
for i := range tracks {
p.order[i] = i
}
p.pos = 0
p.queue = nil
p.queuedIdx = -1
if p.shuffle && len(tracks) > 0 {
p.doShuffle()
}
}
// Add appends tracks to the playlist.
func (p *Playlist) Add(tracks ...Track) {
p.mu.Lock()
defer p.mu.Unlock()
start := len(p.tracks)
p.tracks = append(p.tracks, tracks...)
for i := start; i < len(p.tracks); i++ {
p.order = append(p.order, i)
}
if !p.shuffle || len(tracks) == 0 {
return
}
// Shuffle mode: mix newly added tracks into the upcoming playback order
// without disturbing already-played items or the current position.
if start == 0 {
p.pos = 0
p.doShuffle()
return
}
if p.pos < 0 {
p.pos = 0
}
if p.pos >= len(p.order) {
// Inconsistent internal state; recover by re-shuffling so newly added
// tracks don't end up in sequential order.
p.pos = 0
p.doShuffle()
return
}
// tail is an alias into p.order's backing array; shuffling it
// directly reorders the upcoming entries in p.order in-place.
tail := p.order[p.pos+1:]
for i := len(tail) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
tail[i], tail[j] = tail[j], tail[i]
}
}
// Len returns the number of tracks.
func (p *Playlist) Len() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.tracks)
}
func (p *Playlist) currentTrackIndex() int {
if len(p.order) == 0 {
return -1
}
if p.queuedIdx >= 0 {
return p.queuedIdx
}
return p.currentOrderTrackIndex()
}
func (p *Playlist) currentOrderTrackIndex() int {
if len(p.order) == 0 {
return -1
}
return p.order[p.pos]
}
func (p *Playlist) isPlayable(idx int) bool {
return idx >= 0 && idx < len(p.tracks) && !p.tracks[idx].Unplayable
}
func (p *Playlist) firstPlayableOrderSlot(from, to int) (orderPos int, trackIdx int, ok bool) {
for i := from; i < to && i < len(p.order); i++ {
idx := p.order[i]
if p.isPlayable(idx) {
return i, idx, true
}
}
return -1, -1, false
}
func (p *Playlist) lastPlayableOrderSlot(from int) (orderPos int, trackIdx int, ok bool) {
if from >= len(p.order) {
from = len(p.order) - 1
}
for i := from; i >= 0; i-- {
idx := p.order[i]
if p.isPlayable(idx) {
return i, idx, true
}
}
return -1, -1, false
}
func (p *Playlist) nextPlayableQueued() (trackIdx int, remaining []int, ok bool) {
for i, idx := range p.queue {
if p.isPlayable(idx) {
return idx, p.queue[i+1:], true
}
}
return -1, nil, false
}
func (p *Playlist) nextShuffleWrap() (orderPos int, trackIdx int, ok bool) {
origOrder := slices.Clone(p.order)
origPos := p.pos
p.doShuffle()
if orderPos, trackIdx, ok = p.firstPlayableOrderSlot(1, len(p.order)); ok {
return orderPos, trackIdx, true
}
if orderPos, trackIdx, ok = p.firstPlayableOrderSlot(0, 1); ok {
return orderPos, trackIdx, true
}
p.order = origOrder
p.pos = origPos
return -1, -1, false
}
func (p *Playlist) advanceFromOrder() (orderPos int, trackIdx int, ok bool) {
if orderPos, trackIdx, ok = p.firstPlayableOrderSlot(p.pos+1, len(p.order)); ok {
return orderPos, trackIdx, true
}
if p.repeat != RepeatAll {
return -1, -1, false
}
if p.shuffle && p.atShuffleWrap() {
return p.nextShuffleWrap()
}
return p.firstPlayableOrderSlot(0, len(p.order))
}
func (p *Playlist) resolveSelectedPlayablePos() (orderPos int, trackIdx int, ok bool) {
if len(p.order) == 0 {
return -1, -1, false
}
if idx := p.order[p.pos]; p.isPlayable(idx) {
return p.pos, idx, true
}
if orderPos, trackIdx, ok = p.firstPlayableOrderSlot(p.pos+1, len(p.order)); ok {
return orderPos, trackIdx, true
}
if p.repeat == RepeatAll {
return p.firstPlayableOrderSlot(0, p.pos)
}
return -1, -1, false
}
// Current returns the currently selected track and its index.
func (p *Playlist) Current() (Track, int) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.tracks) == 0 {
return Track{}, -1
}
idx := p.currentTrackIndex()
return p.tracks[idx], idx
}
// Index returns the track index of the current position.
func (p *Playlist) Index() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.currentTrackIndex()
}
func (p *Playlist) CurrentIsQueued() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.queuedIdx >= 0
}
func (p *Playlist) atShuffleWrap() bool {
return p.repeat == RepeatAll && p.shuffle && len(p.queue) == 0 && p.queuedIdx == -1 && p.pos+1 >= len(p.order)
}
// SelectionActivation describes the playable track activated from the selected row.
type SelectionActivation struct {
Track Track
Index int
Skipped bool
}
// ActivateSelected promotes the selected row to the active playable track.
// Queue state is ignored for candidate selection and left unchanged. If no
// playable track can be activated, playlist state is unchanged.
func (p *Playlist) ActivateSelected() (SelectionActivation, bool) {
p.mu.Lock()
defer p.mu.Unlock()
selectedPos := p.pos
orderPos, idx, ok := p.resolveSelectedPlayablePos()
if !ok {
return SelectionActivation{}, false
}
p.pos = orderPos
p.queuedIdx = -1
return SelectionActivation{
Track: p.tracks[idx],
Index: idx,
Skipped: orderPos != selectedPos,
}, true
}
// Next advances to the next track according to queue, repeat, and shuffle.
// Unplayable queued entries are pruned as playback advances. RepeatOne still
// limits playback to the current track.
func (p *Playlist) Next() (Track, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.tracks) == 0 {
return Track{}, false
}
origPos := p.pos
origQueuedIdx := p.queuedIdx
if idx, remaining, ok := p.nextPlayableQueued(); ok {
p.queue = remaining
p.queuedIdx = idx
return p.tracks[idx], true
}
if len(p.queue) > 0 {
p.queue = nil
}
if p.repeat == RepeatOne {
idx := p.currentOrderTrackIndex()
if p.isPlayable(idx) {
p.queuedIdx = -1
return p.tracks[idx], true
}
p.pos = origPos
p.queuedIdx = origQueuedIdx
return Track{}, false
}
orderPos, idx, ok := p.advanceFromOrder()
if !ok {
p.pos = origPos
p.queuedIdx = origQueuedIdx
return Track{}, false
}
p.queuedIdx = -1
p.pos = orderPos
return p.tracks[idx], true
}
// PeekNext returns the next track without advancing the playlist position.
// Returns false when the next track can't be predicted (e.g., shuffle wrap).
func (p *Playlist) PeekNext() (Track, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.tracks) == 0 {
return Track{}, false
}
if idx, _, ok := p.nextPlayableQueued(); ok {
return p.tracks[idx], true
}
if p.repeat == RepeatOne {
idx := p.currentOrderTrackIndex()
if p.isPlayable(idx) {
return p.tracks[idx], true
}
return Track{}, false
}
if p.atShuffleWrap() {
return Track{}, false
}
_, idx, ok := p.advanceFromOrder()
if !ok {
return Track{}, false
}
return p.tracks[idx], true
}
// Prev moves to the previous track, skipping unavailable tracks.
// Wraps around with RepeatAll.
func (p *Playlist) Prev() (Track, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.tracks) == 0 {
return Track{}, false
}
origPos := p.pos
origQueuedIdx := p.queuedIdx
p.queuedIdx = -1
if orderPos, idx, ok := p.lastPlayableOrderSlot(p.pos - 1); ok {
p.pos = orderPos
return p.tracks[idx], true
}
if p.repeat == RepeatAll {
if orderPos, idx, ok := p.lastPlayableOrderSlot(len(p.order) - 1); ok {
p.pos = orderPos
return p.tracks[idx], true
}
}
p.pos = origPos
p.queuedIdx = origQueuedIdx
if origQueuedIdx >= 0 {
return p.tracks[origQueuedIdx], false
}
return p.tracks[p.order[p.pos]], false
}
// SetIndex sets the current position to the given track index.
func (p *Playlist) SetIndex(i int) {
p.mu.Lock()
defer p.mu.Unlock()
p.queuedIdx = -1
for pos, idx := range p.order {
if idx == i {
p.pos = pos
return
}
}
}
// Queue adds a track to the play-next queue by its index.
func (p *Playlist) Queue(trackIdx int) {
p.mu.Lock()
defer p.mu.Unlock()
if trackIdx >= 0 && trackIdx < len(p.tracks) {
p.queue = append(p.queue, trackIdx)
}
}
// Dequeue removes a track from the queue. Returns true if it was found.
func (p *Playlist) Dequeue(trackIdx int) bool {
p.mu.Lock()
defer p.mu.Unlock()
for i, idx := range p.queue {
if idx == trackIdx {
p.queue = slices.Delete(p.queue, i, i+1)
return true
}
}
return false
}
// QueuePosition returns the 1-based position of a track in the queue,
// or 0 if the track is not queued.
func (p *Playlist) QueuePosition(trackIdx int) int {
p.mu.Lock()
defer p.mu.Unlock()
for i, idx := range p.queue {
if idx == trackIdx {
return i + 1
}
}
return 0
}
// QueueLen returns the number of tracks in the queue.
func (p *Playlist) QueueLen() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.queue)
}
// QueueTracks returns copies of the tracks in queue order.
func (p *Playlist) QueueTracks() []Track {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]Track, len(p.queue))
for i, idx := range p.queue {
out[i] = p.tracks[idx]
}
return out
}
// ClearQueue removes all entries from the play-next queue.
func (p *Playlist) ClearQueue() {
p.mu.Lock()
defer p.mu.Unlock()
p.queue = nil
}
// RemoveQueueAt removes the entry at the given 0-based queue position.
func (p *Playlist) RemoveQueueAt(pos int) {
p.mu.Lock()
defer p.mu.Unlock()
if pos >= 0 && pos < len(p.queue) {
p.queue = slices.Delete(p.queue, pos, pos+1)
}
}
// MoveQueue swaps the two entries at the given positions in the play-next queue.
func (p *Playlist) MoveQueue(from, to int) bool {
p.mu.Lock()
defer p.mu.Unlock()
if from < 0 || from >= len(p.queue) || to < 0 || to >= len(p.queue) || from == to {
return false
}
p.queue[from], p.queue[to] = p.queue[to], p.queue[from]
return true
}
// Move swaps the track at position from with the track at position to,
// updating order, queue, and position references so playback is unaffected.
// When shuffle is off, the visual order becomes the new playback order.
func (p *Playlist) Move(from, to int) bool {
p.mu.Lock()
defer p.mu.Unlock()
if from < 0 || from >= len(p.tracks) || to < 0 || to >= len(p.tracks) || from == to {
return false
}
// Swap in the tracks array (visual order).
p.tracks[from], p.tracks[to] = p.tracks[to], p.tracks[from]
// Update order: swap all references so they point at the moved tracks.
for i, idx := range p.order {
if idx == from {
p.order[i] = to
} else if idx == to {
p.order[i] = from
}
}
// Queue also references track indices.
for i, idx := range p.queue {
if idx == from {
p.queue[i] = to
} else if idx == to {
p.queue[i] = from
}
}
if p.queuedIdx == from {
p.queuedIdx = to
} else if p.queuedIdx == to {
p.queuedIdx = from
}
// When shuffle is off, reset order to [0,1,...,n] so playback follows
// the new visual order rather than preserving the old sequence.
if !p.shuffle {
cur := p.order[p.pos]
for i := range p.order {
p.order[i] = i
}
p.pos = cur
}
return true
}
// Remove deletes the track at index idx, updating order, queue, and position
// references so playback state is preserved when possible. Returns true if a
// track was removed. If the removed track was the active one, the position
// stays at the same order slot so playback advances naturally on next.
func (p *Playlist) Remove(idx int) bool {
p.mu.Lock()
defer p.mu.Unlock()
if idx < 0 || idx >= len(p.tracks) {
return false
}
p.tracks = slices.Delete(p.tracks, idx, idx+1)
removedOrderPos := -1
newOrder := p.order[:0]
for i, ord := range p.order {
if ord == idx {
removedOrderPos = i
continue
}
if ord > idx {
ord--
}
newOrder = append(newOrder, ord)
}
p.order = newOrder
if removedOrderPos >= 0 && removedOrderPos < p.pos {
p.pos--
}
if p.pos >= len(p.order) {
p.pos = len(p.order) - 1
}
if p.pos < 0 {
p.pos = 0
}
newQueue := p.queue[:0]
for _, q := range p.queue {
if q == idx {
continue
}
if q > idx {
q--
}
newQueue = append(newQueue, q)
}
p.queue = newQueue
switch {
case p.queuedIdx == idx:
p.queuedIdx = -1
case p.queuedIdx > idx:
p.queuedIdx--
}
return true
}
// SetTrack replaces the track at index i.
func (p *Playlist) SetTrack(i int, t Track) {
p.mu.Lock()
defer p.mu.Unlock()
if i >= 0 && i < len(p.tracks) {
p.tracks[i] = t
}
}
// Tracks returns all tracks in the playlist.
func (p *Playlist) Tracks() []Track {
p.mu.Lock()
defer p.mu.Unlock()
return p.tracks
}
// ToggleBookmark flips the Bookmark flag on the track at the given index.
func (p *Playlist) ToggleBookmark(idx int) {
p.mu.Lock()
defer p.mu.Unlock()
if idx >= 0 && idx < len(p.tracks) {
p.tracks[idx].Bookmark = !p.tracks[idx].Bookmark
}
}
// BookmarkCount returns the number of bookmarked tracks.
func (p *Playlist) BookmarkCount() int {
p.mu.Lock()
defer p.mu.Unlock()
n := 0
for _, t := range p.tracks {
if t.Bookmark {
n++
}
}
return n
}
// ToggleShuffle enables or disables shuffle mode.
// Uses Fisher-Yates shuffle, preserving the current track at position 0.
func (p *Playlist) ToggleShuffle() {
p.mu.Lock()
defer p.mu.Unlock()
p.shuffle = !p.shuffle
if len(p.tracks) == 0 {
return
}
if p.shuffle {
p.doShuffle()
return
}
cur := p.order[p.pos]
p.order = make([]int, len(p.tracks))
for i := range p.order {
p.order[i] = i
}
p.pos = cur
}
func (p *Playlist) doShuffle() {
cur := p.order[p.pos]
others := make([]int, 0, len(p.tracks)-1)
for i := range len(p.tracks) {
if i != cur {
others = append(others, i)
}
}
for i := len(others) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
others[i], others[j] = others[j], others[i]
}
p.order = make([]int, 0, len(p.tracks))
p.order = append(p.order, cur)
p.order = append(p.order, others...)
p.pos = 0
}
// CycleRepeat cycles through Off -> All -> One.
func (p *Playlist) CycleRepeat() {
p.mu.Lock()
defer p.mu.Unlock()
p.repeat = (p.repeat + 1) % 3
}
// SetRepeat sets the repeat mode directly.
func (p *Playlist) SetRepeat(mode RepeatMode) {
p.mu.Lock()
defer p.mu.Unlock()
p.repeat = mode
}
// Shuffled returns whether shuffle is enabled.
func (p *Playlist) Shuffled() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.shuffle
}
// Repeat returns the current repeat mode.
func (p *Playlist) Repeat() RepeatMode {
p.mu.Lock()
defer p.mu.Unlock()
return p.repeat
}