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

453 lines
10 KiB
Go

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
}
}