diff --git a/backend/app.go b/backend/app.go index fe9de0e..cd2e58c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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 { diff --git a/backend/config.go b/backend/config.go index c9a29ea..3ef76ca 100644 --- a/backend/config.go +++ b/backend/config.go @@ -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, diff --git a/go.mod b/go.mod index 966cbd5..b5bdea1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 49d4d78..bff4200 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/player/libmpv.go b/player/libmpv.go deleted file mode 100644 index 6ccb973..0000000 --- a/player/libmpv.go +++ /dev/null @@ -1,195 +0,0 @@ -package player - -/* -#include -#include -#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 -} diff --git a/player/player.go b/player/player.go index 87f6bf8..36ecfde 100644 --- a/player/player.go +++ b/player/player.go @@ -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) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 4859b16..ba12958 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -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() diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index a1114a1..f5a1f6d 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -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()), )) }