Skip to content

Commit 048c32b

Browse files
author
Datanoise
committed
feat: advanced playlist management (drag-drop reorder, queue, shuffle)
1 parent f1851b9 commit 048c32b

3 files changed

Lines changed: 237 additions & 20 deletions

File tree

relay/streamer.go

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package relay
33
import (
44
"context"
55
"fmt"
6+
"math/rand"
67
"os"
78
"path/filepath"
89
"sync"
@@ -28,9 +29,11 @@ type Streamer struct {
2829
Format string
2930
Bitrate int
3031
Playlist []string
32+
Queue []string
3133
CurrentPos int
3234
State StreamerState
3335
Loop bool
36+
Shuffle bool
3437

3538
relay *Relay
3639
cancel context.CancelFunc
@@ -73,6 +76,12 @@ func (s *Streamer) TogglePlay() {
7376
}
7477
}
7578

79+
func (s *Streamer) ToggleShuffle() {
80+
s.mu.Lock()
81+
defer s.mu.Unlock()
82+
s.Shuffle = !s.Shuffle
83+
}
84+
7685
func (s *Streamer) Stop() {
7786
s.mu.Lock()
7887
defer s.mu.Unlock()
@@ -82,6 +91,21 @@ func (s *Streamer) Stop() {
8291
}
8392
}
8493

94+
func (s *Streamer) PushToQueue(path string) {
95+
s.mu.Lock()
96+
defer s.mu.Unlock()
97+
s.Queue = append(s.Queue, path)
98+
}
99+
100+
func (s *Streamer) RemoveFromQueue(index int) {
101+
s.mu.Lock()
102+
defer s.mu.Unlock()
103+
if index < 0 || index >= len(s.Queue) {
104+
return
105+
}
106+
s.Queue = append(s.Queue[:index], s.Queue[index+1:]...)
107+
}
108+
85109
func (s *Streamer) ScanMusicDir() error {
86110
s.mu.Lock()
87111
defer s.mu.Unlock()
@@ -156,6 +180,23 @@ func (sm *StreamerManager) GetStreamer(mount string) *Streamer {
156180
return sm.instances[mount]
157181
}
158182

183+
func (s *Streamer) MovePlaylistItem(from, to int) {
184+
s.mu.Lock()
185+
defer s.mu.Unlock()
186+
if from < 0 || from >= len(s.Playlist) || to < 0 || to >= len(s.Playlist) {
187+
return
188+
}
189+
item := s.Playlist[from]
190+
s.Playlist = append(s.Playlist[:from], s.Playlist[from+1:]...)
191+
192+
// Adjust 'to' if it was after 'from'
193+
newPlaylist := make([]string, 0, len(s.Playlist)+1)
194+
newPlaylist = append(newPlaylist, s.Playlist[:to]...)
195+
newPlaylist = append(newPlaylist, item)
196+
newPlaylist = append(newPlaylist, s.Playlist[to:]...)
197+
s.Playlist = newPlaylist
198+
}
199+
159200
func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
160201
logrus.Infof("Streamer %s starting for mount %s", s.Name, s.OutputMount)
161202

@@ -169,34 +210,45 @@ func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
169210
continue
170211
}
171212

172-
s.mu.RLock()
173-
if len(s.Playlist) == 0 {
174-
s.mu.RUnlock()
175-
time.Sleep(1 * time.Second)
176-
continue
177-
}
178-
179-
if s.CurrentPos >= len(s.Playlist) {
180-
if s.Loop {
181-
s.CurrentPos = 0
213+
s.mu.Lock()
214+
var filePath string
215+
216+
// 1. Check Queue first
217+
if len(s.Queue) > 0 {
218+
filePath = s.Queue[0]
219+
s.Queue = s.Queue[1:]
220+
} else if len(s.Playlist) > 0 {
221+
// 2. Handle Shuffle or Sequential Playlist
222+
if s.Shuffle {
223+
s.CurrentPos = rand.Intn(len(s.Playlist))
182224
} else {
183-
s.State = StateStopped
184-
s.mu.RUnlock()
185-
continue
225+
if s.CurrentPos >= len(s.Playlist) {
226+
if s.Loop {
227+
s.CurrentPos = 0
228+
} else {
229+
s.State = StateStopped
230+
s.mu.Unlock()
231+
continue
232+
}
233+
}
234+
}
235+
filePath = s.Playlist[s.CurrentPos]
236+
if !s.Shuffle {
237+
s.CurrentPos++
186238
}
187239
}
188-
filePath := s.Playlist[s.CurrentPos]
189-
s.mu.RUnlock()
240+
s.mu.Unlock()
241+
242+
if filePath == "" {
243+
time.Sleep(1 * time.Second)
244+
continue
245+
}
190246

191247
err := sm.streamFile(ctx, s, filePath)
192248
if err != nil {
193249
logrus.WithError(err).Errorf("Streamer %s: Failed to stream %s", s.Name, filePath)
194250
time.Sleep(1 * time.Second)
195251
}
196-
197-
s.mu.Lock()
198-
s.CurrentPos++
199-
s.mu.Unlock()
200252
}
201253
}
202254
}

server/server.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ func (s *Server) setupRoutes() *http.ServeMux {
263263
mux.HandleFunc("/admin/delete-webhook", s.handleDeleteWebhook)
264264
mux.HandleFunc("/admin/player/toggle", s.handlePlayerToggle)
265265
mux.HandleFunc("/admin/player/scan", s.handlePlayerScan)
266+
mux.HandleFunc("/admin/player/reorder", s.handlePlayerReorder)
267+
mux.HandleFunc("/admin/player/queue", s.handlePlayerQueue)
268+
mux.HandleFunc("/admin/player/shuffle", s.handlePlayerShuffle)
266269
mux.HandleFunc("/admin/autodj/add", s.handleAddAutoDJ)
267270
mux.HandleFunc("/admin/autodj/delete", s.handleDeleteAutoDJ)
268271
mux.HandleFunc("/admin/autodj/toggle", s.handleToggleAutoDJ)
@@ -2256,6 +2259,83 @@ func (s *Server) handleToggleAutoDJ(w http.ResponseWriter, r *http.Request) {
22562259
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
22572260
}
22582261

2262+
func (s *Server) handlePlayerReorder(w http.ResponseWriter, r *http.Request) {
2263+
if !s.isCSRFSafe(r) {
2264+
http.Error(w, "Forbidden", http.StatusForbidden)
2265+
return
2266+
}
2267+
if _, ok := s.checkAuth(r); !ok {
2268+
return
2269+
}
2270+
2271+
mount := r.FormValue("mount")
2272+
fromStr := r.FormValue("from")
2273+
toStr := r.FormValue("to")
2274+
2275+
streamer := s.StreamerM.GetStreamer(mount)
2276+
if streamer == nil {
2277+
http.Error(w, "Streamer not found", http.StatusNotFound)
2278+
return
2279+
}
2280+
2281+
var from, to int
2282+
fmt.Sscanf(fromStr, "%d", &from)
2283+
fmt.Sscanf(toStr, "%d", &to)
2284+
2285+
streamer.MovePlaylistItem(from, to)
2286+
w.WriteHeader(http.StatusOK)
2287+
}
2288+
2289+
func (s *Server) handlePlayerQueue(w http.ResponseWriter, r *http.Request) {
2290+
if !s.isCSRFSafe(r) {
2291+
http.Error(w, "Forbidden", http.StatusForbidden)
2292+
return
2293+
}
2294+
if _, ok := s.checkAuth(r); !ok {
2295+
return
2296+
}
2297+
2298+
mount := r.FormValue("mount")
2299+
path := r.FormValue("path")
2300+
action := r.FormValue("action") // "add" or "remove"
2301+
2302+
streamer := s.StreamerM.GetStreamer(mount)
2303+
if streamer == nil {
2304+
http.Error(w, "Streamer not found", http.StatusNotFound)
2305+
return
2306+
}
2307+
2308+
if action == "add" {
2309+
streamer.PushToQueue(path)
2310+
} else if action == "remove" {
2311+
var index int
2312+
fmt.Sscanf(r.FormValue("index"), "%d", &index)
2313+
streamer.RemoveFromQueue(index)
2314+
}
2315+
2316+
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
2317+
}
2318+
2319+
func (s *Server) handlePlayerShuffle(w http.ResponseWriter, r *http.Request) {
2320+
if !s.isCSRFSafe(r) {
2321+
http.Error(w, "Forbidden", http.StatusForbidden)
2322+
return
2323+
}
2324+
if _, ok := s.checkAuth(r); !ok {
2325+
return
2326+
}
2327+
2328+
mount := r.FormValue("mount")
2329+
streamer := s.StreamerM.GetStreamer(mount)
2330+
if streamer == nil {
2331+
http.Error(w, "Streamer not found", http.StatusNotFound)
2332+
return
2333+
}
2334+
2335+
streamer.ToggleShuffle()
2336+
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
2337+
}
2338+
22592339
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
22602340
if _, ok := s.checkAuth(r); !ok {
22612341
http.Error(w, "Unauthorized", http.StatusUnauthorized)

server/templates/admin.html

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>TinyIce Admin</title>
77
<script src="/assets/js/lucide.min.js"></script>
8+
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
89
<style>
910
:root {
1011
--bg: #0b0f1a; --surface: #161b2c; --surface-hover: #1e253a;
@@ -59,6 +60,9 @@
5960
.history-mount-btn { padding: 1rem; border: none; background: transparent; color: var(--text-dim); text-align: left; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 0.9rem; width: 100%; }
6061
.history-mount-btn:hover { background: var(--surface-hover); color: var(--text); }
6162
.history-mount-btn.active { color: var(--primary); background: var(--primary-glow); font-weight: 700; }
63+
.playlist-item.active { border-color: var(--primary) !important; background: var(--primary-glow) !important; }
64+
.playlist-item.active span { color: var(--primary); font-weight: 600; }
65+
.sortable-ghost { opacity: 0.4; background: var(--primary-glow) !important; }
6266
</style>
6367
</head>
6468
<body>
@@ -387,7 +391,14 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
387391
Dir: <span style="font-family: monospace; font-size: 0.75rem;">{{.MusicDir}}</span>
388392
</div>
389393
</div>
390-
<div style="display: flex; gap: 0.5rem;">
394+
<div style="display: flex; gap: 0.5rem;">
395+
<form action="/admin/player/shuffle" method="POST">
396+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
397+
<input type="hidden" name="mount" value="{{.OutputMount}}">
398+
<button type="submit" class="btn {{if .Shuffle}}btn-primary{{else}}btn-outline{{end}} btn-sm" title="Toggle Shuffle">
399+
<i data-lucide="shuffle" style="width: 14px; height: 14px;"></i>
400+
</button>
401+
</form>
391402
<a href="/player{{.OutputMount}}" target="_blank" class="btn btn-outline btn-sm"><i data-lucide="headphones" style="width: 14px; height: 14px; margin-right: 4px;"></i> MONITOR</a>
392403
<form action="/admin/autodj/toggle" method="POST">
393404
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
@@ -425,6 +436,50 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
425436
</div>
426437
{{end}}
427438

439+
<div style="margin-top: 1.5rem;">
440+
{{if .Queue}}
441+
<div style="margin-bottom: 1rem;">
442+
<div class="label" style="font-size: 0.65rem; color: var(--primary); margin-bottom: 0.5rem; letter-spacing: 1px;">UP NEXT (QUEUE)</div>
443+
<div style="display: flex; flex-direction: column; gap: 0.4rem;">
444+
{{range $qidx, $qfile := .Queue}}
445+
<div style="display: flex; justify-content: space-between; align-items: center; background: rgba(56, 189, 248, 0.05); padding: 0.5rem; border-radius: 4px; border: 1px dashed var(--primary-glow);">
446+
<span style="font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{$qfile}}</span>
447+
<form action="/admin/player/queue" method="POST">
448+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
449+
<input type="hidden" name="mount" value="{{$.OutputMount}}">
450+
<input type="hidden" name="action" value="remove">
451+
<input type="hidden" name="index" value="{{$qidx}}">
452+
<button type="submit" style="background:none; border:none; color:var(--danger); cursor:pointer;"><i data-lucide="x-circle" style="width:14px; height:14px;"></i></button>
453+
</form>
454+
</div>
455+
{{end}}
456+
</div>
457+
</div>
458+
{{end}}
459+
460+
<div class="label" style="font-size: 0.65rem; color: var(--text-dim); margin-bottom: 0.5rem; letter-spacing: 1px;">PLAYLIST (DRAG TO REORDER)</div>
461+
<div class="playlist-sortable" data-mount="{{.OutputMount}}" style="max-height: 250px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;">
462+
{{$current := .CurrentPos}}
463+
{{range $idx, $file := .Playlist}}
464+
<div class="playlist-item {{if and (not $.Shuffle) (eq $idx $current)}}active{{end}}" data-index="{{$idx}}" style="display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 0.6rem; border-radius: 4px; cursor: grab; border: 1px solid transparent;">
465+
<div style="display: flex; align-items: center; gap: 0.75rem; overflow: hidden;">
466+
<i data-lucide="grip-vertical" style="width: 14px; height: 14px; color: var(--border);"></i>
467+
<span style="font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{$file}}</span>
468+
</div>
469+
<div style="display: flex; gap: 0.5rem;">
470+
<form action="/admin/player/queue" method="POST">
471+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
472+
<input type="hidden" name="mount" value="{{$.OutputMount}}">
473+
<input type="hidden" name="action" value="add">
474+
<input type="hidden" name="path" value="{{$file}}">
475+
<button type="submit" class="btn btn-outline btn-sm" style="padding: 0.2rem 0.4rem;" title="Add to Queue"><i data-lucide="list-plus" style="width: 14px; height: 14px;"></i></button>
476+
</form>
477+
</div>
478+
</div>
479+
{{end}}
480+
</div>
481+
</div>
482+
428483
<div style="margin-top: 1rem; display: flex; gap: 1rem;">
429484
<form action="/admin/player/scan" method="POST" style="flex: 1;">
430485
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
@@ -520,6 +575,35 @@ <h2>Top Connection Scanners (404s)</h2>
520575
}
521576
setInterval(updateAutoDJProgress, 1000);
522577

578+
function initSortable() {
579+
document.querySelectorAll('.playlist-sortable').forEach(function(el) {
580+
new Sortable(el, {
581+
animation: 150,
582+
ghostClass: 'sortable-ghost',
583+
handle: '.lucide-grip-vertical',
584+
onEnd: function(evt) {
585+
const mount = el.dataset.mount;
586+
const from = evt.oldIndex;
587+
const to = evt.newIndex;
588+
if (from === to) return;
589+
590+
const formData = new FormData();
591+
formData.append('csrf', csrfToken);
592+
formData.append('mount', mount);
593+
formData.append('from', from);
594+
formData.append('to', to);
595+
596+
fetch('/admin/player/reorder', {
597+
method: 'POST',
598+
body: formData
599+
}).then(r => {
600+
if (!r.ok) alert('Failed to reorder playlist');
601+
});
602+
}
603+
});
604+
});
605+
}
606+
523607
const csrfToken = "{{.CSRFToken}}";
524608
function showTab(id, updateHash) {
525609
document.querySelectorAll('.tab-content').forEach(function(t) { t.classList.remove('active'); });
@@ -885,6 +969,7 @@ <h2>Top Connection Scanners (404s)</h2>
885969

886970
drawChart();
887971
lucide.createIcons();
972+
initSortable();
888973
if (window.location.hash) { var it = window.location.hash.substring(1); if (document.getElementById(it)) { showTab(it, false); } }
889974
})();
890975
</script>

0 commit comments

Comments
 (0)