chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
//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 {
|
||||
if key, val, ok := strings.Cut(line, ": "); ok {
|
||||
switch key {
|
||||
case "Name":
|
||||
cur.Name = val
|
||||
cur.Active = val == defaultSink
|
||||
case "Description":
|
||||
cur.Description = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 cleanup that restores the env.
|
||||
func PrepareAudioDevice(device string) func() {
|
||||
prev, hadPrev := os.LookupEnv("PIPEWIRE_NODE")
|
||||
os.Setenv("PIPEWIRE_NODE", device)
|
||||
return func() {
|
||||
if hadPrev {
|
||||
os.Setenv("PIPEWIRE_NODE", prev)
|
||||
} else {
|
||||
os.Unsetenv("PIPEWIRE_NODE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchAudioDevice moves cliamp's audio stream to a different sink.
|
||||
// Falls back to changing the system default if the stream can't be found.
|
||||
func SwitchAudioDevice(deviceName string) error {
|
||||
out, err := exec.Command("pactl", "list", "sink-inputs").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pactl: %w", err)
|
||||
}
|
||||
|
||||
pidStr := strconv.Itoa(os.Getpid())
|
||||
sinkInputIdx := -1
|
||||
currentIdx := 0
|
||||
props := map[string]string{}
|
||||
|
||||
// Parse all sink-inputs, collecting properties per entry.
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Sink Input #") {
|
||||
// Check previous entry before moving on.
|
||||
if sinkInputIdx < 0 {
|
||||
sinkInputIdx = matchCliamp(props, pidStr, currentIdx)
|
||||
}
|
||||
if sinkInputIdx >= 0 {
|
||||
break
|
||||
}
|
||||
currentIdx, _ = strconv.Atoi(strings.TrimPrefix(line, "Sink Input #"))
|
||||
props = map[string]string{}
|
||||
continue
|
||||
}
|
||||
if key, val, ok := strings.Cut(line, "="); ok {
|
||||
props[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(val), `"`)
|
||||
}
|
||||
}
|
||||
// Check the last entry.
|
||||
if sinkInputIdx < 0 {
|
||||
sinkInputIdx = matchCliamp(props, pidStr, currentIdx)
|
||||
}
|
||||
|
||||
if sinkInputIdx >= 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// Fallback: change the system default sink.
|
||||
if out, err := exec.Command("pactl", "set-default-sink", deviceName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("set-default-sink: %s (%w)", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchCliamp checks if a sink-input's properties belong to cliamp.
|
||||
func matchCliamp(props map[string]string, pidStr string, idx int) int {
|
||||
if props["application.process.id"] == pidStr {
|
||||
return idx
|
||||
}
|
||||
if strings.EqualFold(props["application.process.binary"], "cliamp") {
|
||||
return idx
|
||||
}
|
||||
if strings.Contains(strings.ToLower(props["application.name"]), "cliamp") {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user