diff --git a/config/config.go b/config/config.go index ab653b2..9b0166e 100644 --- a/config/config.go +++ b/config/config.go @@ -147,6 +147,7 @@ type Config struct { Compact bool // compact mode: cap frame width at 80 columns PaddingH int // horizontal padding for the UI frame (default 3) PaddingV int // vertical padding for the UI frame (default 1) + AudioDevice string // preferred audio output device name (empty = system default) Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials Spotify SpotifyConfig // optional Spotify provider (requires Premium) YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider @@ -354,6 +355,8 @@ func Load() (Config, error) { } case "compact": cfg.Compact = val == "true" + case "audio_device": + cfg.AudioDevice = strings.Trim(val, `"'`) case "padding_horizontal": if v, err := strconv.Atoi(val); err == nil { cfg.PaddingH = v diff --git a/config/flags.go b/config/flags.go index db232a2..2ba3077 100644 --- a/config/flags.go +++ b/config/flags.go @@ -22,6 +22,7 @@ type Overrides struct { BitDepth *int Play *bool Compact *bool + AudioDevice *string } // Apply merges non-nil overrides into cfg and clamps the result. @@ -68,6 +69,9 @@ func (o Overrides) Apply(cfg *Config) { if o.Play != nil { cfg.AutoPlay = *o.Play } + if o.AudioDevice != nil { + cfg.AudioDevice = *o.AudioDevice + } cfg.clamp() } @@ -76,7 +80,19 @@ func (o Overrides) Apply(cfg *Config) { // and correctly treats negative numbers as flag values rather than flags. // // Returned action is one of "help", "version", "upgrade", or "" (run). -func ParseFlags(args []string) (action string, ov Overrides, positional []string, err error) { +func ParseFlags(rawArgs []string) (action string, ov Overrides, positional []string, err error) { + // Normalize --flag=value into --flag value so the parser handles both forms. + var args []string + for _, a := range rawArgs { + if strings.HasPrefix(a, "--") { + if eqIdx := strings.IndexByte(a, '='); eqIdx > 0 { + args = append(args, a[:eqIdx], a[eqIdx+1:]) + continue + } + } + args = append(args, a) + } + // Subcommand: cliamp plugins [list|install|remove] [args...] if len(args) > 0 && args[0] == "plugins" { if len(args) == 1 { @@ -189,6 +205,15 @@ func ParseFlags(args []string) (action string, ov Overrides, positional []string return "", ov, nil, e } ov.BitDepth = &v + case "--audio-device": + v, e := requireNextString(args, &i, arg) + if e != nil { + return "", ov, nil, e + } + if strings.ToLower(v) == "list" { + return "list-audio-devices", ov, nil, nil + } + ov.AudioDevice = &v default: return "", ov, nil, fmt.Errorf("unknown flag: %s", arg) diff --git a/main.go b/main.go index 2207c33..a69847b 100644 --- a/main.go +++ b/main.go @@ -155,6 +155,12 @@ func run(overrides config.Overrides, positional []string) error { pl := playlist.New() pl.Add(resolved.Tracks...) + // Configure audio output device before speaker init. + if cfg.AudioDevice != "" { + cleanup := player.PrepareAudioDevice(cfg.AudioDevice) + defer cleanup() + } + // Resolve sample rate: 0 means auto-detect from the system's default // output audio device (e.g. 48 kHz for USB-C headphones). Falls back // to 44100 Hz if detection is unavailable or returns an unusable value. @@ -338,6 +344,7 @@ Audio engine: --buffer-ms Speaker buffer in milliseconds (50–500) --resample-quality Resample quality factor (1–4) --bit-depth PCM bit depth: 16 (default) or 32 (lossless) + --audio-device Audio output device (use --audio-device=list to show available devices) Provider: --provider Default provider: radio, navidrome, plex, jellyfin, spotify, yt, youtube, ytmusic (default: radio) @@ -429,6 +436,24 @@ func main() { os.Exit(1) } return + case "list-audio-devices": + devices, err := player.ListAudioDevices() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(devices) == 0 { + fmt.Println("No audio output devices found.") + } else { + for _, d := range devices { + marker := " " + if d.Active { + marker = "* " + } + fmt.Printf("%s%-50s %s\n", marker, d.Description, d.Name) + } + } + return case "plugins": fmt.Println(pluginsHelpText) return diff --git a/player/audio_device.go b/player/audio_device.go new file mode 100644 index 0000000..86ee8a3 --- /dev/null +++ b/player/audio_device.go @@ -0,0 +1,9 @@ +package player + +// AudioDevice represents an available audio output device (sink/endpoint). +type AudioDevice struct { + Index int + Name string // internal identifier (sink name, UID, or device ID) + Description string // human-readable label + Active bool // true when this is the current default +} diff --git a/player/audio_device_linux.go b/player/audio_device_linux.go new file mode 100644 index 0000000..73fb96e --- /dev/null +++ b/player/audio_device_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package player + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// ListAudioDevices returns available output sinks via pactl. +// Works on PulseAudio and PipeWire (via pipewire-pulse). +func ListAudioDevices() ([]AudioDevice, error) { + defaultSink := "" + if out, err := exec.Command("pactl", "get-default-sink").Output(); err == nil { + defaultSink = strings.TrimSpace(string(out)) + } + + out, err := exec.Command("pactl", "list", "sinks").Output() + if err != nil { + return nil, fmt.Errorf("pactl: %w (is PulseAudio/PipeWire running?)", err) + } + + var devices []AudioDevice + var cur *AudioDevice + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Sink #") { + idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink #")) + devices = append(devices, AudioDevice{Index: idx}) + cur = &devices[len(devices)-1] + } else if cur != nil { + switch { + case strings.HasPrefix(line, "Name: "): + cur.Name = strings.TrimPrefix(line, "Name: ") + cur.Active = cur.Name == defaultSink + case strings.HasPrefix(line, "Description: "): + cur.Description = strings.TrimPrefix(line, "Description: ") + } + } + } + + return devices, nil +} + +// PrepareAudioDevice sets PIPEWIRE_NODE so the PipeWire ALSA plugin +// routes this process's audio to the named device. +// Must be called before player.New(). Returns a no-op cleanup. +func PrepareAudioDevice(device string) func() { + os.Setenv("PIPEWIRE_NODE", device) + return func() {} +} + +// SwitchAudioDevice moves this process's active audio stream to a +// different output at runtime via pactl move-sink-input. +func SwitchAudioDevice(deviceName string) error { + pid := os.Getpid() + + out, err := exec.Command("pactl", "list", "sink-inputs").Output() + if err != nil { + return fmt.Errorf("pactl: %w", err) + } + + sinkInputIdx := -1 + currentIdx := 0 + inEntry := false + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Sink Input #") { + idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink Input #")) + currentIdx = idx + inEntry = true + } + if inEntry && strings.Contains(line, "application.process.id") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + pidStr := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if pidStr == strconv.Itoa(pid) { + sinkInputIdx = currentIdx + break + } + } + } + } + + if sinkInputIdx < 0 { + return fmt.Errorf("no active audio stream found for PID %d", pid) + } + + cmd := exec.Command("pactl", "move-sink-input", + strconv.Itoa(sinkInputIdx), deviceName) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("move-sink-input: %s (%w)", + strings.TrimSpace(string(out)), err) + } + + return nil +} diff --git a/player/audio_device_macos.go b/player/audio_device_macos.go new file mode 100644 index 0000000..6d6d907 --- /dev/null +++ b/player/audio_device_macos.go @@ -0,0 +1,175 @@ +// player/audio_device_macos.go — macOS Core Audio output device enumeration & selection. + +//go:build darwin && !ios + +package player + +/* +#cgo LDFLAGS: -framework CoreAudio -framework CoreFoundation +#include +#include +#include + +static AudioDeviceID caDefaultOutput() { + AudioObjectPropertyAddress addr = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + AudioDeviceID id = kAudioObjectUnknown; + UInt32 sz = sizeof(id); + AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, &sz, &id); + return id; +} + +static int caSetDefaultOutput(AudioDeviceID id) { + AudioObjectPropertyAddress addr = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + return AudioObjectSetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, sizeof(id), &id) == noErr ? 0 : -1; +} + +static int caOutputChannels(AudioDeviceID id) { + AudioObjectPropertyAddress addr = { + kAudioDevicePropertyStreamConfiguration, + kAudioObjectPropertyScopeOutput, + kAudioObjectPropertyElementMain, + }; + UInt32 sz = 0; + if (AudioObjectGetPropertyDataSize(id, &addr, 0, NULL, &sz) != noErr || sz == 0) return 0; + AudioBufferList *bl = (AudioBufferList *)malloc(sz); + if (AudioObjectGetPropertyData(id, &addr, 0, NULL, &sz, bl) != noErr) { free(bl); return 0; } + int ch = 0; + for (UInt32 i = 0; i < bl->mNumberBuffers; i++) ch += bl->mBuffers[i].mNumberChannels; + free(bl); + return ch; +} + +static char* caStr(CFStringRef s) { + if (s == NULL) return NULL; + CFIndex len = CFStringGetMaximumSizeForEncoding(CFStringGetLength(s), kCFStringEncodingUTF8)+1; + char *buf = (char*)malloc(len); + CFStringGetCString(s, buf, len, kCFStringEncodingUTF8); + CFRelease(s); + return buf; +} + +static char* caDevName(AudioDeviceID id) { + AudioObjectPropertyAddress a = {kAudioObjectPropertyName, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + CFStringRef s = NULL; UInt32 sz = sizeof(s); + if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL; + return caStr(s); +} + +static char* caDevUID(AudioDeviceID id) { + AudioObjectPropertyAddress a = {kAudioDevicePropertyDeviceUID, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + CFStringRef s = NULL; UInt32 sz = sizeof(s); + if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL; + return caStr(s); +} + +typedef struct { AudioDeviceID id; char *name; char *uid; } CADev; + +static int caListOutputs(CADev **out, int *count) { + AudioObjectPropertyAddress a = {kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + UInt32 sz = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &a, 0, NULL, &sz) != noErr) { *count=0; return -1; } + int n = sz / sizeof(AudioDeviceID); + AudioDeviceID *ids = (AudioDeviceID*)malloc(sz); + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, 0, NULL, &sz, ids) != noErr) { free(ids); *count=0; return -1; } + int oc = 0; + for (int i=0; i0) oc++; } + CADev *devs = (CADev*)calloc(oc, sizeof(CADev)); + int j = 0; + for (int i=0; i 0 { + m.devicePicker.cursor-- + } else if len(m.devicePicker.devices) > 0 { + m.devicePicker.cursor = len(m.devicePicker.devices) - 1 + } + case "down", "j": + if m.devicePicker.cursor < len(m.devicePicker.devices)-1 { + m.devicePicker.cursor++ + } else { + m.devicePicker.cursor = 0 + } + case "enter": + if len(m.devicePicker.devices) > 0 && m.devicePicker.cursor < len(m.devicePicker.devices) { + dev := m.devicePicker.devices[m.devicePicker.cursor] + m.devicePicker.visible = false + return switchDeviceCmd(dev.Name) + } + case "esc", "d": + m.devicePicker.visible = false + } + return nil +} diff --git a/ui/model/model.go b/ui/model/model.go index 0132373..1c2f1d6 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -30,6 +30,7 @@ const ( screenMain topLevelScreen = iota screenKeymap screenThemePicker + screenDevicePicker screenFileBrowser screenNavBrowser screenPlaylistManager @@ -206,6 +207,9 @@ type Model struct { // Track info overlay (metadata details) showInfo bool + // Audio device picker overlay + devicePicker devicePickerState + // Full-screen visualizer mode (Shift+V) fullVis bool @@ -225,6 +229,8 @@ func (m Model) activeScreen() topLevelScreen { return screenKeymap case m.themePicker.visible: return screenThemePicker + case m.devicePicker.visible: + return screenDevicePicker case m.fileBrowser.visible: return screenFileBrowser case m.navBrowser.visible: diff --git a/ui/model/state.go b/ui/model/state.go index c431b2e..0e2d0b2 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -8,6 +8,7 @@ import ( "time" "cliamp/lyrics" + "cliamp/player" "cliamp/playlist" "cliamp/provider" ) @@ -167,6 +168,14 @@ type reconnectState struct { at time.Time } +// devicePickerState holds state for the audio device picker overlay. +type devicePickerState struct { + visible bool + devices []player.AudioDevice + cursor int + loading bool +} + type saveState struct { pendingDownloads int } diff --git a/ui/model/update.go b/ui/model/update.go index 0b91dd7..aa97d9f 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "cliamp/config" "cliamp/mpris" "cliamp/playlist" "cliamp/provider" @@ -561,6 +562,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.provLoading = true return m, fetchPlaylistsCmd(m.provider) + case devicesListedMsg: + m.devicePicker.loading = false + if msg.err != nil { + m.status.Showf(statusTTLDefault, "Device list failed: %s", msg.err) + m.devicePicker.visible = false + } else { + m.devicePicker.devices = msg.devices + } + return m, nil + + case deviceSwitchedMsg: + if msg.err != nil { + m.status.Showf(statusTTLDefault, "Switch failed: %s", msg.err) + } else { + m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name) + // Persist the selection to config. + _ = config.Save("audio_device", fmt.Sprintf("%q", msg.name)) + } + return m, nil + case mpris.InitMsg: m.mpris = msg.Svc m.notifyAll() diff --git a/ui/model/view.go b/ui/model/view.go index 5cdcc78..aad3dd1 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -53,6 +53,8 @@ func (m Model) View() string { return m.renderKeymapOverlay() case screenThemePicker: return m.renderThemePicker() + case screenDevicePicker: + return m.renderDeviceOverlay() case screenFileBrowser: return m.renderFileBrowser() case screenNavBrowser: diff --git a/ui/model/view_overlays.go b/ui/model/view_overlays.go index 76ddfc6..9dbb7c4 100644 --- a/ui/model/view_overlays.go +++ b/ui/model/view_overlays.go @@ -10,6 +10,56 @@ import ( "cliamp/theme" ) +func (m Model) renderDeviceOverlay() string { + lines := []string{ + titleStyle.Render("A U D I O D E V I C E S"), + "", + } + + if m.devicePicker.loading { + lines = append(lines, dimStyle.Render(" Loading devices...")) + lines = append(lines, "", helpKey("Esc", "Cancel")) + return m.centerOverlay(strings.Join(lines, "\n")) + } + + if len(m.devicePicker.devices) == 0 { + lines = append(lines, dimStyle.Render(" No audio output devices found.")) + lines = append(lines, "", helpKey("Esc", "Close")) + return m.centerOverlay(strings.Join(lines, "\n")) + } + + maxVisible := 12 + scroll := scrollStart(m.devicePicker.cursor, maxVisible) + rendered := 0 + + for i := scroll; i < len(m.devicePicker.devices) && i < scroll+maxVisible; i++ { + d := m.devicePicker.devices[i] + label := d.Description + if label == "" { + label = d.Name + } + suffix := "" + if d.Active { + suffix = " " + activeToggle.Render("●") + } + if i == m.devicePicker.cursor { + lines = append(lines, playlistSelectedStyle.Render("> "+label)+suffix) + } else { + lines = append(lines, dimStyle.Render(" "+label)+suffix) + } + rendered++ + } + + lines = padLines(lines, maxVisible, rendered) + + if len(m.devicePicker.devices) > maxVisible { + lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d devices", m.devicePicker.cursor+1, len(m.devicePicker.devices)))) + } + + lines = append(lines, "", helpKey("↑↓", "Navigate ")+helpKey("Enter", "Select ")+helpKey("Esc", "Cancel")) + return m.centerOverlay(strings.Join(lines, "\n")) +} + func (m Model) renderKeymapOverlay() string { lines := []string{ titleStyle.Render("K E Y M A P"),