Merge pull request #121 from dweymouth/feature/choose-audio-device

Add drop-down list to Playback settings tab to choose audio device
This commit is contained in:
Drew Weymouth
2023-04-05 21:12:08 -07:00
committed by GitHub
8 changed files with 144 additions and 232 deletions
+42 -12
View File
@@ -56,19 +56,9 @@ func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*A
if err := a.initMPV(); err != nil {
return nil, err
}
a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100)
a.Player.SetVolume(a.Config.LocalPlayback.Volume)
rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack}
if !sharedutil.StringSliceContains(rgainOpts, a.Config.ReplayGain.Mode) {
a.Config.ReplayGain.Mode = ReplayGainNone
if err := a.setupMPV(); err != nil {
return nil, err
}
a.Player.SetReplayGainOptions(player.ReplayGainOptions{
Mode: player.ReplayGainMode(a.Config.ReplayGain.Mode),
PreventClipping: a.Config.ReplayGain.PreventClipping,
PreampGain: a.Config.ReplayGain.PreampGainDB,
})
a.Player.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive)
a.ServerManager = NewServerManager(appName)
a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling)
@@ -108,6 +98,46 @@ func (a *App) initMPV() error {
return nil
}
func (a *App) setupMPV() error {
a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100)
a.Player.SetVolume(a.Config.LocalPlayback.Volume)
devs, err := a.Player.ListAudioDevices()
if err != nil {
return err
}
desiredDevice := a.Config.LocalPlayback.AudioDeviceName
var desiredDeviceAvailable bool
for _, dev := range devs {
if dev.Name == desiredDevice {
desiredDeviceAvailable = true
break
}
}
if !desiredDeviceAvailable {
// The audio device the user has configured is not available.
// Use the default (autoselect) device but leave the setting unchanged,
// in case the device is later available on a subsequent run of the app
// (e.g. a USB audio device that is currently unplugged)
desiredDevice = "auto"
}
a.Player.SetAudioDevice(desiredDevice)
rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack}
if !sharedutil.StringSliceContains(rgainOpts, a.Config.ReplayGain.Mode) {
a.Config.ReplayGain.Mode = ReplayGainNone
}
a.Player.SetReplayGainOptions(player.ReplayGainOptions{
Mode: player.ReplayGainMode(a.Config.ReplayGain.Mode),
PreventClipping: a.Config.ReplayGain.PreventClipping,
PreampGain: a.Config.ReplayGain.PreampGainDB,
})
a.Player.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive)
return nil
}
func (a *App) LoginToDefaultServer(string) error {
serverCfg := a.Config.GetDefaultServer()
if serverCfg == nil {
+3
View File
@@ -49,6 +49,7 @@ type PlaylistPageConfig struct {
}
type LocalPlaybackConfig struct {
AudioDeviceName string
AudioExclusive bool
InMemoryCacheSizeMB int
Volume int
@@ -108,6 +109,8 @@ func DefaultConfig(appVersionTag string) *Config {
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
},
LocalPlayback: LocalPlaybackConfig{
// "auto" is the name to pass to MPV for autoselecting the output device
AudioDeviceName: "auto",
AudioExclusive: false,
InMemoryCacheSizeMB: 30,
Volume: 100,
+1
View File
@@ -5,6 +5,7 @@ go 1.19
require (
fyne.io/fyne/v2 v2.3.3
github.com/20after4/configdir v0.1.1
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
github.com/dweymouth/go-subsonic v0.0.0-20230210044542-537b9238299b
github.com/google/uuid v1.3.0
github.com/pelletier/go-toml v1.9.3
+2
View File
@@ -77,6 +77,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230331041414-b548301c117c h1:w3Q+TShgmyoTBeWoYEZC5yflP15bkLxAzHWSmxjHQXs=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230331041414-b548301c117c/go.mod h1:MABZ23XXF2K24hl3rva+Di/UbMpibBMAO+qDtDCSe8k=
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
github.com/dweymouth/go-subsonic v0.0.0-20230210044542-537b9238299b h1:8JbTKYDdQg6JKu7ZDbEaVbXxutc3pRF58yNvTVMBHec=
github.com/dweymouth/go-subsonic v0.0.0-20230210044542-537b9238299b/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-195
View File
@@ -1,195 +0,0 @@
package player
/*
#include <mpv/client.h>
#include <stdlib.h>
#cgo LDFLAGS: -lmpv
char** newCharArray(int size) {
return calloc(sizeof(char*), size);
}
void setCharArrayIdx(char** a, int i, char* s) {
a[i] = s;
}
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
type MPVFormat int
const (
MPVFormatDouble MPVFormat = C.MPV_FORMAT_DOUBLE
MPVFormatFlag MPVFormat = C.MPV_FORMAT_FLAG
MPVFormatString MPVFormat = C.MPV_FORMAT_STRING
MPVFormatInt64 MPVFormat = C.MPV_FORMAT_INT64
)
type MPVEventID int
const (
MPVEventNone MPVEventID = C.MPV_EVENT_NONE
MPVEventStartFile MPVEventID = C.MPV_EVENT_START_FILE
MPVEventEndFile MPVEventID = C.MPV_EVENT_END_FILE
MPVEventFileLoaded MPVEventID = C.MPV_EVENT_FILE_LOADED
MPVEventIdle MPVEventID = C.MPV_EVENT_IDLE
MPVEventAudioReconfig MPVEventID = C.MPV_EVENT_AUDIO_RECONFIG
MPVEventSeek MPVEventID = C.MPV_EVENT_SEEK
MPVEventPlaybackRestart MPVEventID = C.MPV_EVENT_PLAYBACK_RESTART
MPVEventPropertyChange MPVEventID = C.MPV_EVENT_PROPERTY_CHANGE
)
type MPVEvent struct {
ID MPVEventID
ErrCode int
Data unsafe.Pointer
UserData uint64
}
type libmpv struct {
handle *C.mpv_handle
}
func CreateMPV() (libmpv, error) {
handle := C.mpv_create()
if handle == nil {
return libmpv{}, errors.New("failed to create mpv instance")
}
return libmpv{handle}, nil
}
func (m libmpv) Initialize() error {
return toMPVError(C.mpv_initialize(m.handle))
}
func (m libmpv) Command(cmd []string) error {
cArray := C.newCharArray(C.int(len(cmd) + 1))
if cArray == nil {
return errors.New("calloc failed")
}
defer C.free(unsafe.Pointer(cArray))
for i, s := range cmd {
cStr := C.CString(s)
C.setCharArrayIdx(cArray, C.int(i), cStr)
defer C.free(unsafe.Pointer(cStr))
}
return toMPVError(C.mpv_command(m.handle, cArray))
}
func (m libmpv) SetOption(name string, format MPVFormat, value any) error {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
p := toPointer(format, value)
return toMPVError(C.mpv_set_option(m.handle, cname, C.mpv_format(format), p))
}
func (m libmpv) SetOptionString(name, value string) error {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cvalue := C.CString(value)
defer C.free(unsafe.Pointer(cvalue))
return toMPVError(C.mpv_set_option_string(m.handle, cname, cvalue))
}
func (m libmpv) SetProperty(name string, format MPVFormat, value any) error {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
p := toPointer(format, value)
return toMPVError(C.mpv_set_property(m.handle, cname, C.mpv_format(format), p))
}
func (m libmpv) SetPropertyString(name, value string) error {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
cvalue := C.CString(value)
defer C.free(unsafe.Pointer(cvalue))
return toMPVError(C.mpv_set_property_string(m.handle, cname, cvalue))
}
func (m libmpv) GetProperty(name string, format MPVFormat) (any, error) {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
switch format {
case MPVFormatDouble:
var cdbl C.double
err := toMPVError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cdbl)))
if err != nil {
return nil, err
}
return float64(cdbl), nil
case MPVFormatFlag:
var cint C.int
err := toMPVError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cint)))
if err != nil {
return nil, err
}
return cint == 1, nil
case MPVFormatInt64:
var cint64 C.int64_t
err := toMPVError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cint64)))
if err != nil {
return nil, err
}
return int64(cint64), nil
default:
return nil, errors.New("unsupported mpv format")
}
}
func (m libmpv) WaitEvent(timeout float64) MPVEvent {
var cevent *C.mpv_event
cevent = C.mpv_wait_event(m.handle, C.double(timeout))
if cevent == nil {
return MPVEvent{ID: MPVEventNone}
}
e := MPVEvent{
ID: MPVEventID(cevent.event_id),
UserData: uint64(cevent.reply_userdata),
ErrCode: int(cevent.error),
Data: cevent.data,
}
return e
}
func (m libmpv) TerminateDestroy() {
C.mpv_terminate_destroy(m.handle)
}
func toMPVError(errcode C.int) error {
if errcode == C.MPV_ERROR_SUCCESS {
return nil
}
return fmt.Errorf("mpv error %d: %s", int(errcode), C.GoString(C.mpv_error_string(C.int(errcode))))
}
func toPointer(format MPVFormat, value any) unsafe.Pointer {
var ptr unsafe.Pointer = nil
switch format {
case MPVFormatDouble:
v := C.double(value.(float64))
ptr = unsafe.Pointer(&v)
case MPVFormatInt64:
i, ok := value.(int64)
if !ok {
i = int64(value.(int))
}
v := C.int64_t(i)
ptr = unsafe.Pointer(&v)
case MPVFormatFlag:
v := C.int(0)
if value.(bool) {
v = C.int(1)
}
ptr = unsafe.Pointer(&v)
case MPVFormatString:
ptr = unsafe.Pointer(&[]byte(value.(string))[0])
}
return ptr
}
+52 -18
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"strconv"
"github.com/dweymouth/go-mpv"
)
// Error returned by many Player functions if called before the player has not been initialized.
@@ -55,10 +57,22 @@ type ReplayGainOptions struct {
// Fallback gain intentionally omitted
}
// Information about a specific audio device.
// Returned by ListAudioDevices.
type AudioDevice struct {
// The name of the audio device.
// This is the string to pass to SetAudioDevice.
Name string
// The description of the audio device.
// This is the friendly string that should be used in UIs.
Description string
}
// Player encapsulates the mpv instance and provides functions
// to control it and to check its status.
type Player struct {
mpv libmpv
mpv *mpv.Mpv
initialized bool
vol int
replayGainOpts ReplayGainOptions
@@ -99,10 +113,8 @@ func NewWithClientName(c string) *Player {
// Most Player functions will return ErrUnitialized if called before Init.
func (p *Player) Init(maxCacheMB int) error {
if !p.initialized {
m, err := CreateMPV()
if err != nil {
return err
}
m := mpv.Create()
m.SetOptionString("idle", "yes")
m.SetOptionString("video", "no")
m.SetOptionString("audio-display", "no")
@@ -117,7 +129,7 @@ func (p *Player) Init(maxCacheMB int) error {
if p.vol < 0 {
p.vol = 100
}
m.SetOption("volume", MPVFormatInt64, p.vol)
m.SetOption("volume", mpv.FORMAT_INT64, p.vol)
p.SetAudioExclusive(p.audioExclusive)
if p.haveRGainOpts {
@@ -246,7 +258,7 @@ func (p *Player) SetVolume(vol int) error {
vol = 0
}
if p.initialized {
err := p.mpv.SetProperty("volume", MPVFormatInt64, vol)
err := p.mpv.SetProperty("volume", mpv.FORMAT_INT64, vol)
if err == nil {
p.vol = vol
}
@@ -266,7 +278,7 @@ func (p *Player) SetReplayGainOptions(options ReplayGainOptions) error {
if err := p.mpv.SetPropertyString("replaygain", string(options.Mode)); err != nil {
return err
}
if err := p.mpv.SetProperty("replaygain-preamp", MPVFormatDouble, options.PreampGain); err != nil {
if err := p.mpv.SetProperty("replaygain-preamp", mpv.FORMAT_DOUBLE, options.PreampGain); err != nil {
return err
}
clip := "no"
@@ -300,7 +312,7 @@ func (p *Player) GetVolume() int {
}
func (p *Player) setPaused(paused bool) error {
return p.mpv.SetProperty("pause", MPVFormatFlag, paused)
return p.mpv.SetProperty("pause", mpv.FORMAT_FLAG, paused)
}
// Start playback from the first track in the play queue.
@@ -362,8 +374,8 @@ func (p *Player) GetStatus() Status {
return p.status
}
pos, _ := p.mpv.GetProperty("playback-time", MPVFormatDouble)
dur, _ := p.mpv.GetProperty("duration", MPVFormatDouble)
pos, _ := p.mpv.GetProperty("playback-time", mpv.FORMAT_DOUBLE)
dur, _ := p.mpv.GetProperty("duration", mpv.FORMAT_DOUBLE)
if pos != nil {
p.status.TimePos = pos.(float64)
}
@@ -376,8 +388,30 @@ func (p *Player) GetStatus() Status {
return p.status
}
// List available audio devices.
func (p *Player) ListAudioDevices() ([]AudioDevice, error) {
n, err := p.mpv.GetProperty("audio-device-list", mpv.FORMAT_NODE)
if err != nil {
return nil, err
}
nodeArr := n.(*mpv.Node).Data.([]*mpv.Node)
devices := make([]AudioDevice, len(nodeArr))
for i, node := range nodeArr {
dev := node.Data.(map[string]*mpv.Node)
name := dev["name"].Data.(string)
desc := dev["description"].Data.(string)
devices[i] = AudioDevice{Name: name, Description: desc}
}
return devices, nil
}
func (p *Player) SetAudioDevice(deviceName string) error {
return p.mpv.SetPropertyString("audio-device", deviceName)
}
func (p *Player) getInt64Property(propName string) (int64, error) {
playpos, err := p.mpv.GetProperty(propName, MPVFormatInt64)
playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64)
if err != nil {
return -1, err
}
@@ -463,19 +497,19 @@ func (p *Player) eventHandler(ctx context.Context) {
return
default:
e := p.mpv.WaitEvent(1 /*timeout seconds*/)
if e.ID != MPVEventNone {
if e.Event_Id != mpv.EVENT_NONE {
//log.Printf("mpv event: %+v\n", e)
}
switch e.ID {
case MPVEventPlaybackRestart:
switch e.Event_Id {
case mpv.EVENT_PLAYBACK_RESTART:
if p.seeking {
p.seeking = false
}
case MPVEventSeek:
case mpv.EVENT_SEEK:
for _, cb := range p.onSeek {
cb()
}
case MPVEventFileLoaded:
case mpv.EVENT_FILE_LOADED:
if p.status.State == Paused {
// seek while paused switches to a new file
// mpv does not fire seek event in this case
@@ -489,7 +523,7 @@ func (p *Player) eventHandler(ctx context.Context) {
cb(pos)
}
}
case MPVEventIdle:
case mpv.EVENT_IDLE:
p.status.Duration = 0
p.status.TimePos = 0
p.setState(Stopped)
+11 -1
View File
@@ -5,6 +5,7 @@ import (
"log"
"strconv"
"supersonic/backend"
"supersonic/player"
"supersonic/ui/dialogs"
"supersonic/ui/util"
"supersonic/ui/widgets"
@@ -305,13 +306,22 @@ func (c *Controller) ShowAboutDialog() {
}
func (c *Controller) ShowSettingsDialog() {
dlg := dialogs.NewSettingsDialog(c.App.Config)
devs, err := c.App.Player.ListAudioDevices()
if err != nil {
log.Printf("error listing audio devices: %v", err)
devs = []player.AudioDevice{{Name: "auto", Description: "Autoselect device"}}
}
dlg := dialogs.NewSettingsDialog(c.App.Config, devs)
dlg.OnReplayGainSettingsChanged = func() {
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
}
dlg.OnAudioExclusiveSettingChanged = func() {
c.App.Player.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive)
}
dlg.OnAudioDeviceSettingChanged = func() {
c.App.Player.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName)
}
pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas())
dlg.OnDismiss = func() {
pop.Hide()
+33 -6
View File
@@ -4,7 +4,9 @@ import (
"math"
"strconv"
"supersonic/backend"
"supersonic/player"
"supersonic/ui/layouts"
"supersonic/ui/util"
"supersonic/ui/widgets"
"unicode"
@@ -21,15 +23,18 @@ type SettingsDialog struct {
OnReplayGainSettingsChanged func()
OnAudioExclusiveSettingChanged func()
OnAudioDeviceSettingChanged func()
OnDismiss func()
config *backend.Config
config *backend.Config
audioDevices []player.AudioDevice
content fyne.CanvasObject
}
func NewSettingsDialog(config *backend.Config) *SettingsDialog {
s := &SettingsDialog{config: config}
// TODO: having this depend on the player package for the AudioDevice type is kinda gross. Refactor.
func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDevice) *SettingsDialog {
s := &SettingsDialog{config: config, audioDevices: audioDeviceList}
s.ExtendBaseWidget(s)
tabs := container.NewAppTabs(
@@ -133,6 +138,24 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
}
func (s *SettingsDialog) createPlaybackTab() *container.TabItem {
deviceList := make([]string, len(s.audioDevices))
var selIndex int
for i, dev := range s.audioDevices {
deviceList[i] = dev.Description
if dev.Name == s.config.LocalPlayback.AudioDeviceName {
selIndex = i
}
}
deviceSelect := widget.NewSelect(deviceList, nil)
deviceSelect.SetSelectedIndex(selIndex)
deviceSelect.OnChanged = func(_ string) {
dev := s.audioDevices[deviceSelect.SelectedIndex()]
s.config.LocalPlayback.AudioDeviceName = dev.Name
if s.OnAudioDeviceSettingChanged != nil {
s.OnAudioDeviceSettingChanged()
}
}
replayGainSelect := widget.NewSelect([]string{"None", "Album", "Track"}, nil)
replayGainSelect.OnChanged = func(_ string) {
switch replayGainSelect.SelectedIndex() {
@@ -189,14 +212,18 @@ func (s *SettingsDialog) createPlaybackTab() *container.TabItem {
audioExclusive.Checked = s.config.LocalPlayback.AudioExclusive
return container.NewTabItem("Playback", container.NewVBox(
container.New(&layouts.MaxPadLayout{PadTop: 5},
container.New(layout.NewFormLayout(),
widget.NewLabel("Audio device"), container.NewBorder(nil, nil, nil, util.NewHSpace(70), deviceSelect),
layout.NewSpacer(), container.NewHBox(audioExclusive, layout.NewSpacer()),
)),
container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15}, widget.NewSeparator()),
widget.NewRichText(&widget.TextSegment{Text: "ReplayGain", Style: boldStyle}),
container.New(layout.NewFormLayout(),
widget.NewLabel("ReplayGain mode"), replayGainSelect,
widget.NewLabel("ReplayGain mode"), container.NewGridWithColumns(2, replayGainSelect),
widget.NewLabel("ReplayGain preamp"), container.NewHBox(preampGain, widget.NewLabel("dB")),
widget.NewLabel("Prevent clipping"), container.NewHBox(preventClipping, layout.NewSpacer()),
),
container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15}, widget.NewSeparator()),
container.NewHBox(audioExclusive, layout.NewSpacer()),
))
}