Skip to content

Commit 6521b43

Browse files
author
Datanoise
committed
feat: transport controls (play/stop/next) and fix drag-drop reordering
1 parent bacf2c2 commit 6521b43

3 files changed

Lines changed: 116 additions & 52 deletions

File tree

relay/streamer.go

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type Streamer struct {
4141
cancel context.CancelFunc
4242
mu sync.RWMutex
4343

44+
fileCancel context.CancelFunc
45+
4446
// Stats
4547
BytesStreamed int64
4648
CurrentFile string
@@ -70,6 +72,14 @@ func (s *Streamer) Play() {
7072
s.State = StatePlaying
7173
}
7274

75+
func (s *Streamer) Next() {
76+
s.mu.Lock()
77+
defer s.mu.Unlock()
78+
if s.fileCancel != nil {
79+
s.fileCancel()
80+
}
81+
}
82+
7383
func (s *Streamer) TogglePlay() {
7484
s.mu.Lock()
7585
defer s.mu.Unlock()
@@ -261,14 +271,10 @@ func (s *Streamer) MovePlaylistItem(from, to int) {
261271
return
262272
}
263273
item := s.Playlist[from]
274+
// Remove
264275
s.Playlist = append(s.Playlist[:from], s.Playlist[from+1:]...)
265-
266-
// Adjust 'to' if it was after 'from'
267-
newPlaylist := make([]string, 0, len(s.Playlist)+1)
268-
newPlaylist = append(newPlaylist, s.Playlist[:to]...)
269-
newPlaylist = append(newPlaylist, item)
270-
newPlaylist = append(newPlaylist, s.Playlist[to:]...)
271-
s.Playlist = newPlaylist
276+
// Insert
277+
s.Playlist = append(s.Playlist[:to], append([]string{item}, s.Playlist[to:]...)...)
272278
}
273279

274280
func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
@@ -313,20 +319,30 @@ func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
313319
}
314320
s.mu.Unlock()
315321

316-
if filePath == "" {
317-
time.Sleep(1 * time.Second)
318-
continue
319-
}
320-
321-
err := sm.streamFile(ctx, s, filePath)
322-
if err != nil {
323-
logrus.WithError(err).Errorf("Streamer %s: Failed to stream %s", s.Name, filePath)
324-
time.Sleep(1 * time.Second)
322+
if filePath == "" {
323+
time.Sleep(1 * time.Second)
324+
continue
325+
}
326+
327+
// Create a per-file context for skipping
328+
fileCtx, fileCancel := context.WithCancel(ctx)
329+
s.mu.Lock()
330+
s.fileCancel = fileCancel
331+
s.mu.Unlock()
332+
333+
err := sm.streamFile(fileCtx, s, filePath)
334+
if err != nil && fileCtx.Err() == nil {
335+
logrus.WithError(err).Errorf("Streamer %s: Failed to stream %s", s.Name, filePath)
336+
time.Sleep(1 * time.Second)
337+
}
338+
339+
s.mu.Lock()
340+
s.fileCancel = nil
341+
fileCancel()
342+
s.mu.Unlock()
343+
}
344+
}
325345
}
326-
}
327-
}
328-
}
329-
330346
func (sm *StreamerManager) streamFile(ctx context.Context, s *Streamer, path string) error {
331347
f, err := os.Open(path)
332348
if err != nil {

server/server.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
267267
mux.HandleFunc("/admin/player/reorder", s.handlePlayerReorder)
268268
mux.HandleFunc("/admin/player/queue", s.handlePlayerQueue)
269269
mux.HandleFunc("/admin/player/shuffle", s.handlePlayerShuffle)
270+
mux.HandleFunc("/admin/player/next", s.handlePlayerNext)
270271
mux.HandleFunc("/admin/player/files", s.handlePlayerFiles)
271272
mux.HandleFunc("/admin/player/playlist-action", s.handlePlayerPlaylistAction)
272273
mux.HandleFunc("/admin/autodj/add", s.handleAddAutoDJ)
@@ -2357,6 +2358,26 @@ func (s *Server) handlePlayerShuffle(w http.ResponseWriter, r *http.Request) {
23572358
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
23582359
}
23592360

2361+
func (s *Server) handlePlayerNext(w http.ResponseWriter, r *http.Request) {
2362+
if !s.isCSRFSafe(r) {
2363+
http.Error(w, "Forbidden", http.StatusForbidden)
2364+
return
2365+
}
2366+
if _, ok := s.checkAuth(r); !ok {
2367+
return
2368+
}
2369+
2370+
mount := r.FormValue("mount")
2371+
streamer := s.StreamerM.GetStreamer(mount)
2372+
if streamer == nil {
2373+
http.Error(w, "Streamer not found", http.StatusNotFound)
2374+
return
2375+
}
2376+
2377+
streamer.Next()
2378+
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
2379+
}
2380+
23602381
func (s *Server) handlePlayerFiles(w http.ResponseWriter, r *http.Request) {
23612382
if _, ok := s.checkAuth(r); !ok {
23622383
http.Error(w, "Unauthorized", http.StatusUnauthorized)

server/templates/admin.html

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -405,19 +405,30 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
405405
{{if .MPDServer}}MPD: <span style="color: var(--success); font-weight: bold;">PORT {{.MPDServer.Port}}</span> |{{end}}
406406
Dir: <span style="font-family: monospace; font-size: 0.75rem;">{{.MusicDir}}</span>
407407
</div> </div>
408-
<div style="display: flex; gap: 0.5rem;">
409-
<form action="/admin/player/shuffle" method="POST">
410-
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
411-
<input type="hidden" name="mount" value="{{.OutputMount}}">
412-
<button type="submit" class="btn {{if .Shuffle}}btn-primary{{else}}btn-outline{{end}} btn-sm" title="Toggle Shuffle">
413-
<i data-lucide="shuffle" style="width: 14px; height: 14px;"></i>
414-
</button>
415-
</form>
416-
{{if eq .State 1}}
417-
<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>
418-
{{end}}
419-
<form action="/admin/autodj/toggle" method="POST">
420-
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
408+
<div style="display: flex; gap: 0.5rem; align-items: center;">
409+
<div class="btn-group" style="display: flex; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 2px;">
410+
<form action="/admin/player/shuffle" method="POST" style="margin:0;">
411+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
412+
<input type="hidden" name="mount" value="{{.OutputMount}}">
413+
<button type="submit" class="btn {{if .Shuffle}}btn-primary{{else}}btn-outline{{end}} btn-sm" style="border:none; background:transparent;" title="Toggle Shuffle">
414+
<i data-lucide="shuffle" style="width: 14px; height: 14px;"></i>
415+
</button>
416+
</form>
417+
{{if eq .State 1}}
418+
<form action="/admin/player/next" method="POST" style="margin:0;">
419+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
420+
<input type="hidden" name="mount" value="{{.OutputMount}}">
421+
<button type="submit" class="btn btn-outline btn-sm" style="border:none; background:transparent;" title="Skip to Next">
422+
<i data-lucide="skip-forward" style="width: 14px; height: 14px;"></i>
423+
</button>
424+
</form>
425+
{{end}}
426+
</div>
427+
428+
{{if eq .State 1}}
429+
<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>
430+
{{end}}
431+
<form action="/admin/autodj/toggle" method="POST"> <input type="hidden" name="csrf" value="{{$.CSRFToken}}">
421432
<input type="hidden" name="mount" value="{{.OutputMount}}">
422433
<button type="submit" class="btn {{if eq .State 1}}btn-warning{{else}}btn-primary{{end}} btn-sm">
423434
{{if eq .State 1}}STOP{{else}}START{{end}}
@@ -431,25 +442,35 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
431442
</div>
432443
</div>
433444

434-
{{if eq .State 1}}
435-
<div style="background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; border: 1px solid var(--border);">
436-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
437-
<div class="label" style="font-size: 0.7rem; color: var(--text-dim);">NOW PLAYING</div>
438-
<div style="font-size: 0.7rem; color: var(--primary); font-family: monospace;">
439-
Playlist: {{.CurrentPos}} / {{len .Playlist}}
440-
</div>
441-
</div>
442-
<div class="song-title" style="font-size: 1rem; color: var(--text); margin-bottom: 0.75rem;">
443-
{{if .CurrentFile}}{{.CurrentFile}}{{else}}Preparing...{{end}}
444-
</div>
445-
<div style="height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden; position: relative;">
446-
<div class="progress-bar-adj" data-start="{{.CurrentFileTime.Unix}}" data-duration="{{.CurrentFileDuration.Seconds}}" style="height: 100%; background: var(--primary); width: 0%; transition: width 1s linear;"></div>
445+
{{if eq .State 1}}
446+
<div style="background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; border: 1px solid var(--border);">
447+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
448+
<div>
449+
<div class="label" style="font-size: 0.7rem; color: var(--text-dim);">NOW PLAYING</div>
450+
<div class="song-title" style="font-size: 1rem; color: var(--text); margin-bottom: 0.75rem;">
451+
{{if .CurrentFile}}{{.CurrentFile}}{{else}}Preparing...{{end}}
452+
</div>
447453
</div>
448-
<div style="display: flex; justify-content: space-between; margin-top: 0.4rem; font-size: 0.7rem; color: var(--text-dim); font-family: monospace;">
449-
<span class="progress-time-current">00:00</span>
450-
<span class="progress-time-total" id="duration-{{.OutputMount}}">00:00</span>
451-
</div> </div>
452-
{{end}}
454+
<div style="display: flex; gap: 0.4rem;">
455+
<form action="/admin/player/toggle" method="POST" style="margin:0;">
456+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
457+
<input type="hidden" name="mount" value="{{.OutputMount}}">
458+
<button type="submit" class="btn btn-outline btn-sm" style="padding: 0.3rem;" title="Pause/Stop Streamer"><i data-lucide="square" style="width: 12px; height: 12px; fill: currentColor;"></i></button>
459+
</form>
460+
</div>
461+
</div>
462+
<div style="height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden; position: relative;">
463+
<div class="progress-bar-adj" data-start="{{.CurrentFileTime.Unix}}" data-duration="{{.CurrentFileDuration.Seconds}}" style="height: 100%; background: var(--primary); width: 0%; transition: width 1s linear;"></div>
464+
</div>
465+
<div style="display: flex; justify-content: space-between; margin-top: 0.4rem; font-size: 0.7rem; color: var(--text-dim); font-family: monospace;">
466+
<span class="progress-time-current">00:00</span>
467+
<div style="font-size: 0.7rem; color: var(--primary); font-family: monospace;">
468+
Playlist: {{.CurrentPos}} / {{len .Playlist}}
469+
</div>
470+
<span class="progress-time-total" id="duration-{{.OutputMount}}">00:00</span>
471+
</div>
472+
</div>
473+
{{end}}
453474

454475
<div style="margin-top: 1.5rem;">
455476
{{if .Queue}}
@@ -666,7 +687,8 @@ <h2>Top Connection Scanners (404s)</h2>
666687
handle: '.lucide-grip-vertical',
667688
onEnd: function(evt) {
668689
const mount = el.dataset.mount;
669-
const from = evt.oldIndex;
690+
// Use dataset.index to get the ORIGINAL index from the backend
691+
const from = parseInt(evt.item.dataset.index);
670692
const to = evt.newIndex;
671693
if (from === to) return;
672694

@@ -681,6 +703,11 @@ <h2>Top Connection Scanners (404s)</h2>
681703
body: formData
682704
}).then(r => {
683705
if (!r.ok) alert('Failed to reorder playlist');
706+
// We don't reload here to avoid losing scroll position,
707+
// but ideally we should refresh the data-index attributes
708+
// if we want multiple moves without a reload.
709+
// For simplicity, let's just reload for now to keep indices in sync.
710+
window.location.reload();
684711
});
685712
}
686713
});

0 commit comments

Comments
 (0)