package main import ( "fmt" "os" "os/signal" "strings" "sync" "syscall" "time" tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/applog" "github.com/bjarneo/cliamp/external/local" "github.com/bjarneo/cliamp/internal/playback" "github.com/bjarneo/cliamp/internal/resume" "github.com/bjarneo/cliamp/ipc" "github.com/bjarneo/cliamp/mediactl" "github.com/bjarneo/cliamp/player" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/ui/model" ) // runDaemon runs cliamp without a TUI: serves IPC against the shared // player+playlist, auto-advances tracks, exits on SIGINT/SIGTERM. func runDaemon(p *player.Player, pl *playlist.Playlist, localProv *local.Provider, autoPlay bool) error { fmt.Fprintf(os.Stderr, "cliamp: running headless (socket: %s)\n", ipc.DefaultSocketPath()) applog.Info("daemon: starting headless mode") d := &daemon{ player: p, playlist: pl, localProv: localProv, quit: make(chan struct{}, 1), } // Wire MPRIS (Linux) / NowPlaying (macOS) so playerctl and OS media // keys see the daemon. mediactl callbacks dispatch back through d.Send. svc, mcErr := mediactl.New(func(msg tea.Msg) { d.Send(msg) }) if mcErr != nil { fmt.Fprintf(os.Stderr, "media controls: %v\n", mcErr) applog.Warn("daemon: media controls unavailable: %v", mcErr) } if svc != nil { defer svc.Close() d.notifier = svc } if autoPlay && pl.Len() > 0 { d.mu.Lock() d.playCurrent() d.mu.Unlock() } srv, err := ipc.NewServer(ipc.DefaultSocketPath(), d) if err != nil { return fmt.Errorf("ipc: %w", err) } defer srv.Close() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { select { case <-sigCh: applog.Info("daemon: signal received, shutting down") d.saveResume() return nil case <-d.quit: applog.Info("daemon: quit requested via media control, shutting down") d.saveResume() return nil case <-ticker.C: d.tick() } } } // daemon implements ipc.Dispatcher for headless mode. The mutex covers // playlist state and "what plays next" decisions; the player itself is // internally thread-safe so blocking I/O (Play, PlayYTDL) runs without it. type daemon struct { mu sync.Mutex player *player.Player playlist *playlist.Playlist localProv *local.Provider notifier playback.Notifier quit chan struct{} } func (d *daemon) Send(msg any) { d.mu.Lock() defer d.mu.Unlock() switch m := msg.(type) { case ipc.PlayMsg, playback.PlayMsg: if d.player.IsPaused() { d.player.TogglePause() } else if !d.player.IsPlaying() && d.playlist.Len() > 0 { d.playCurrent() } case ipc.PauseMsg, playback.PauseMsg: if d.player.IsPlaying() && !d.player.IsPaused() { d.player.TogglePause() } case playback.PlayPauseMsg: d.toggle() case playback.StopMsg: d.player.Stop() case playback.NextMsg: d.nextTrack() case playback.PrevMsg: d.prevTrack() case playback.QuitMsg: select { case d.quit <- struct{}{}: default: } case ipc.VolumeMsg: d.player.SetVolume(m.DB) case playback.SetVolumeMsg: d.player.SetVolume(m.VolumeDB) case ipc.SeekMsg: _ = d.player.Seek(m.Offset) case playback.SeekMsg: _ = d.player.Seek(m.Offset) case playback.SetPositionMsg: cur := d.player.Position() _ = d.player.Seek(m.Position - cur) case ipc.LoadMsg: d.handleLoad(m) case ipc.QueueMsg: d.playlist.Add(playlist.TrackFromPath(m.Path)) case ipc.ThemeMsg: reply(m.Reply, ipc.Response{OK: false, Error: "theme not available in headless mode"}) case ipc.VisMsg: reply(m.Reply, ipc.Response{OK: false, Error: "visualizer not available in headless mode"}) case ipc.ShuffleMsg: d.handleShuffle(m) case ipc.RepeatMsg: d.handleRepeat(m) case ipc.MonoMsg: d.handleMono(m) case ipc.SpeedMsg: d.player.SetSpeed(m.Speed) reply(m.Reply, ipc.Response{OK: true, Speed: d.player.Speed()}) case ipc.EQMsg: d.handleEQ(m) case ipc.DeviceMsg: d.handleDevice(m) case ipc.StatusRequestMsg: reply(m.Reply, d.statusResponse()) } } // tick advances to the next track when the current one has drained, and // republishes playback state to the media-control notifier. Daemon mode // skips gapless preloading; small inter-track gaps are fine. func (d *daemon) tick() { d.mu.Lock() if d.player.IsPlaying() && !d.player.IsPaused() && d.player.Drained() { d.nextTrack() } state := d.snapshotState() d.mu.Unlock() if d.notifier != nil { d.notifier.Update(state) } } // snapshotState builds a playback.State for OS media-control notifiers. // Caller must hold d.mu. func (d *daemon) snapshotState() playback.State { status := playback.StatusStopped if d.player.IsPlaying() { if d.player.IsPaused() { status = playback.StatusPaused } else { status = playback.StatusPlaying } } track, _ := d.playlist.Current() return playback.State{ Status: status, Track: playback.Track{ Title: track.Title, Artist: track.Artist, Album: track.Album, Genre: track.Genre, TrackNumber: track.TrackNumber, URL: track.Path, Duration: d.player.Duration(), }, VolumeDB: d.player.Volume(), Position: d.player.Position(), Seekable: d.player.Seekable(), } } func (d *daemon) playCurrent() { track, idx := d.playlist.Current() if idx < 0 { return } d.playTrack(track) } // playTrack temporarily releases d.mu around the blocking Play call so // concurrent IPC requests (notably `cliamp status`) don't stall for the // 1-3s of HTTP/yt-dlp setup. The player itself serializes internally. // Caller must hold d.mu; the lock is held again on return. func (d *daemon) playTrack(track playlist.Track) { dur := time.Duration(track.DurationSecs) * time.Second d.mu.Unlock() var err error if playlist.IsYTDL(track.Path) { err = d.player.PlayYTDL(track.Path, dur) } else { err = d.player.Play(track.Path, dur) } d.mu.Lock() if err != nil { applog.Warn("daemon: play %q: %v", track.Path, err) } } func (d *daemon) toggle() { if !d.player.IsPlaying() { if d.playlist.Len() > 0 { d.playCurrent() } return } d.player.TogglePause() } func (d *daemon) nextTrack() { track, ok := d.playlist.Next() if !ok { d.player.Stop() return } d.playTrack(track) } func (d *daemon) prevTrack() { if d.player.Position() > 3*time.Second { track, idx := d.playlist.Current() if idx < 0 { return } if d.player.Seekable() { _ = d.player.Seek(-d.player.Position()) return } d.playTrack(track) return } track, ok := d.playlist.Prev() if !ok { return } d.playTrack(track) } func (d *daemon) handleLoad(m ipc.LoadMsg) { if d.localProv == nil { reply(m.Reply, ipc.Response{OK: false, Error: "local provider unavailable"}) return } tracks, err := d.localProv.Tracks(m.Playlist) if err != nil { reply(m.Reply, ipc.Response{OK: false, Error: fmt.Sprintf("playlist %q: %v", m.Playlist, err)}) return } d.playlist.Replace(tracks) d.playCurrent() reply(m.Reply, ipc.Response{OK: true, Playlist: m.Playlist, Total: len(tracks)}) } func (d *daemon) handleShuffle(m ipc.ShuffleMsg) { switch strings.ToLower(m.Name) { case "on": if !d.playlist.Shuffled() { d.playlist.ToggleShuffle() } case "off": if d.playlist.Shuffled() { d.playlist.ToggleShuffle() } default: d.playlist.ToggleShuffle() } shuffled := d.playlist.Shuffled() reply(m.Reply, ipc.Response{OK: true, Shuffle: &shuffled}) } func (d *daemon) handleRepeat(m ipc.RepeatMsg) { switch strings.ToLower(m.Name) { case "off": d.playlist.SetRepeat(playlist.RepeatOff) case "all": d.playlist.SetRepeat(playlist.RepeatAll) case "one": d.playlist.SetRepeat(playlist.RepeatOne) default: d.playlist.CycleRepeat() } reply(m.Reply, ipc.Response{OK: true, Repeat: d.playlist.Repeat().String()}) } func (d *daemon) handleMono(m ipc.MonoMsg) { switch strings.ToLower(m.Name) { case "on": if !d.player.Mono() { d.player.ToggleMono() } case "off": if d.player.Mono() { d.player.ToggleMono() } default: d.player.ToggleMono() } mono := d.player.Mono() reply(m.Reply, ipc.Response{OK: true, Mono: &mono}) } func (d *daemon) handleEQ(m ipc.EQMsg) { if m.Band > 0 || (m.Band == 0 && m.Name == "") { d.player.SetEQBand(m.Band, m.Value) reply(m.Reply, ipc.Response{OK: true, EQPreset: "Custom"}) return } if m.Name == "" { reply(m.Reply, ipc.Response{OK: false, Error: "eq requires a preset name or --band"}) return } preset, ok := model.EQPresetByName(m.Name) if !ok { reply(m.Reply, ipc.Response{OK: false, Error: fmt.Sprintf("unknown EQ preset %q", m.Name)}) return } for i, gain := range preset.Bands { d.player.SetEQBand(i, gain) } reply(m.Reply, ipc.Response{OK: true, EQPreset: preset.Name}) } func (d *daemon) handleDevice(m ipc.DeviceMsg) { if strings.EqualFold(m.Name, "list") { devices, err := player.ListAudioDevices() if err != nil { reply(m.Reply, ipc.Response{OK: false, Error: fmt.Sprintf("list devices: %v", err)}) return } var lines []string for _, dev := range devices { marker := " " if dev.Active { marker = "* " } lines = append(lines, fmt.Sprintf("%s%s", marker, dev.Name)) } reply(m.Reply, ipc.Response{OK: true, Device: strings.Join(lines, "\n")}) return } if err := player.SwitchAudioDevice(m.Name); err != nil { reply(m.Reply, ipc.Response{OK: false, Error: fmt.Sprintf("switch device: %v", err)}) return } reply(m.Reply, ipc.Response{OK: true, Device: m.Name}) } func (d *daemon) statusResponse() ipc.Response { resp := ipc.Response{OK: true} switch { case d.player.IsPlaying() && !d.player.IsPaused(): resp.State = "playing" case d.player.IsPaused(): resp.State = "paused" default: resp.State = "stopped" } if cur, _ := d.playlist.Current(); cur.Path != "" { resp.Track = &ipc.TrackInfo{ Title: cur.Title, Artist: cur.Artist, Path: cur.Path, } } pos, dur := d.player.PositionAndDuration() resp.Position = pos.Seconds() resp.Duration = dur.Seconds() resp.Volume = d.player.Volume() resp.Index = d.playlist.Index() resp.Total = d.playlist.Len() shuffled := d.playlist.Shuffled() resp.Shuffle = &shuffled resp.Repeat = d.playlist.Repeat().String() mono := d.player.Mono() resp.Mono = &mono resp.Speed = d.player.Speed() return resp } func (d *daemon) saveResume() { d.mu.Lock() defer d.mu.Unlock() track, idx := d.playlist.Current() if idx < 0 || track.Path == "" { return } pos := int(d.player.Position().Seconds()) if pos <= 0 { return } resume.Save(track.Path, pos, "") } func reply(ch chan ipc.Response, resp ipc.Response) { if ch != nil { ch <- resp } }