From cb017037768292e64b56e93dae6893cb0577b8b9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 15 Jan 2025 17:42:44 -0800 Subject: [PATCH 1/7] initial implementation of Windows SMTC integration --- backend/app.go | 52 ++++++++++++ backend/smtc.go | 159 ++++++++++++++++++++++++++++++++++++ backend/smtc_cfuncs.go | 16 ++++ backend/smtc_unsupported.go | 32 ++++++++ main.go | 38 +++++---- 5 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 backend/smtc.go create mode 100644 backend/smtc_cfuncs.go create mode 100644 backend/smtc_unsupported.go diff --git a/backend/app.go b/backend/app.go index 06f1fc96..9a014ab6 100644 --- a/backend/app.go +++ b/backend/app.go @@ -12,6 +12,7 @@ import ( "reflect" "runtime" "slices" + "strings" "time" "github.com/dweymouth/supersonic/backend/ipc" @@ -45,6 +46,7 @@ type App struct { LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler + WinSMTC *SMTC ipcServer ipc.IPCServer // UI callbacks to be set in main @@ -165,11 +167,14 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // OS media center integrations + // Linux MPRIS a.setupMPRIS(displayAppName) + // MacOS MPNowPlayingInfoCenter InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) { a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally return a.ImageManager.GetCoverArtUrl(id) }) + // Windows SMTC is initialized from main once we have a window HWND. a.startConfigWriter(a.bgrndCtx) @@ -331,6 +336,50 @@ func (a *App) setupMPRIS(mprisAppName string) { a.MPRISHandler.Start() } +func (a *App) SetupWindowsSMTC(hwnd uintptr) { + smtc, err := InitSMTCForWindow(hwnd) + if err != nil { + log.Printf("error initializing SMTC: %d", err) + return + } + a.WinSMTC = smtc + + smtc.OnButtonPressed(func(btn SMTCButton) { + switch btn { + case SMTCButtonPlay: + a.PlaybackManager.Continue() + case SMTCButtonPause: + a.PlaybackManager.Pause() + case SMTCButtonNext: + a.PlaybackManager.SeekNext() + case SMTCButtonPrevious: + a.PlaybackManager.SeekBackOrPrevious() + case SMTCButtonStop: + a.PlaybackManager.Stop() + } + }) + smtc.OnSeek(func(millis int) { + a.PlaybackManager.SeekSeconds(float64(millis) / 1000) + }) + + a.PlaybackManager.OnSongChange(func(nowPlaying mediaprovider.MediaItem, _ *mediaprovider.Track) { + if nowPlaying == nil { + smtc.UpdateMetadata("", "") + return + } + artist := strings.Join(nowPlaying.Metadata().Artists, ", ") + smtc.UpdateMetadata(nowPlaying.Metadata().Name, artist) + smtc.UpdatePosition(0, nowPlaying.Metadata().Duration*1000) + }) + a.PlaybackManager.OnSeek(func() { + dur := a.PlaybackManager.NowPlaying().Metadata().Duration + smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) + }) + a.PlaybackManager.OnPlaying(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying) }) + a.PlaybackManager.OnPaused(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePaused) }) + a.PlaybackManager.OnStopped(func() { smtc.UpdatePlaybackState(SMTCPlaybackStateStopped) }) +} + func (a *App) LoginToDefaultServer(string) error { serverCfg := a.ServerManager.GetDefaultServer() if serverCfg == nil { @@ -370,6 +419,9 @@ func (a *App) Shutdown() { a.ipcServer.Shutdown(a.bgrndCtx) } a.MPRISHandler.Shutdown() + if a.WinSMTC != nil { + a.WinSMTC.Shutdown() + } a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() diff --git a/backend/smtc.go b/backend/smtc.go new file mode 100644 index 00000000..6df9069b --- /dev/null +++ b/backend/smtc.go @@ -0,0 +1,159 @@ +//go:build windows + +package backend + +/* +#cgo CFLAGS: -I . +void btn_callback_cgo(int in); +void seek_callback_cgo(int in); +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +type SMTCPlaybackState int +type SMTCButton int + +const ( + // constants from smtc.h in github.com/supersonic-app/smtc-dll + SMTCPlaybackStateStopped SMTCPlaybackState = 2 + SMTCPlaybackStatePlaying SMTCPlaybackState = 3 + SMTCPlaybackStatePaused SMTCPlaybackState = 4 + + SMTCButtonPlay SMTCButton = 0 + SMTCButtonPause SMTCButton = 1 + SMTCButtonStop SMTCButton = 2 + SMTCButtonPrevious SMTCButton = 4 + SMTCButtonNext SMTCButton = 5 +) + +type SMTC struct { + dll *windows.DLL + + onButtonPressed func(SMTCButton) + onSeek func(int) +} + +var smtcInstance *SMTC + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + dll, err := windows.LoadDLL("smtc.dll") + if err != nil { + return nil, err + } + + proc, err := dll.FindProc("InitializeForWindow") + if err != nil { + return nil, err + } + + hr, _, _ := proc.Call(hwnd, uintptr(unsafe.Pointer(C.btn_callback_cgo)), uintptr(unsafe.Pointer(C.seek_callback_cgo))) + if hr < 0 { + return nil, fmt.Errorf("InitializeForWindow failed with HRESULT=%d", hr) + } + + smtcInstance = &SMTC{dll: dll} + return smtcInstance, nil +} + +func (s *SMTC) OnButtonPressed(f func(SMTCButton)) { + s.onButtonPressed = f +} + +func (s *SMTC) OnSeek(f func(millis int)) { + s.onSeek = f +} + +func (s *SMTC) Shutdown() { + if s.dll == nil { + return + } + proc, err := s.dll.FindProc("Destroy") + if err == nil { + proc.Call() + } + + s.dll.Release() + s.dll = nil + smtcInstance = nil +} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePlaybackState") + if err != nil { + return err + } + + if hr, _, _ := proc.Call(uintptr(state)); hr < 0 { + return fmt.Errorf("UpdatePlaybackState failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + utfTitle, err := windows.UTF16PtrFromString(title) + if err != nil { + return err + } + + utfArtist, err := windows.UTF16PtrFromString(artist) + if err != nil { + return err + } + + proc, err := s.dll.FindProc("UpdateMetadata") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist))) + if hr < 0 { + return fmt.Errorf("UpdateMetadata failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePosition") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis)) + if hr < 0 { + return fmt.Errorf("UpdatePosition failed with HRESULT=%d", hr) + } + return nil +} + +//export btnCallback +func btnCallback(in int) { + if smtcInstance != nil && smtcInstance.onButtonPressed != nil { + smtcInstance.onButtonPressed(SMTCButton(in)) + } +} + +//export seekCallback +func seekCallback(millis int) { + if smtcInstance != nil && smtcInstance.onSeek != nil { + smtcInstance.onSeek(millis) + } +} diff --git a/backend/smtc_cfuncs.go b/backend/smtc_cfuncs.go new file mode 100644 index 00000000..9c1b317c --- /dev/null +++ b/backend/smtc_cfuncs.go @@ -0,0 +1,16 @@ +//go:build windows + +package backend + +/* +void btn_callback_cgo(int in) { + void btnCallback(int); + btnCallback(in); +} + +void seek_callback_cgo(int in) { + void seekCallback(int); + seekCallback(in); +} +*/ +import "C" diff --git a/backend/smtc_unsupported.go b/backend/smtc_unsupported.go new file mode 100644 index 00000000..36c0ba19 --- /dev/null +++ b/backend/smtc_unsupported.go @@ -0,0 +1,32 @@ +//go:build !windows + +package backend + +import "errors" + +type SMTCPlaybackState int + +const ( + SMTCPlaybackStateStopped SMTCPlaybackState = 0 + SMTCPlaybackStatePlaying SMTCPlaybackState = 1 + SMTCPlaybackStatePaused SMTCPlaybackState = 2 +) + +type SMTC struct{} + +var smtcUnsupportedErr = errors.New("SMTC is not supported on this platformo") + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + return nil, smtcUnsupportedErr +} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + return smtcUnsupportedErr +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + return smtcUnsupportedErr +} + +func (s *SMTC) Shutdown() { +} diff --git a/main.go b/main.go index d41eedfc..c1dc73e8 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "golang.org/x/sys/windows" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/ui" @@ -87,27 +89,33 @@ func main() { } }() - // slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 - if runtime.GOOS == "linux" { - workaroundWindowSize := sync.OnceFunc(func() { - go func() { - isWayland := false - mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { - _, isWayland = ctx.(*driver.WaylandWindowContext) - }) - if !isWayland { + startupOnceTasks := sync.OnceFunc(func() { + mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { + // intialize Windows SMTC + if runtime.GOOS == "windows" { + if maj, _, _ := windows.RtlGetNtVersionNumbers(); maj >= 10 { + // SMTC is only available from Windows 10 (10.0.10240) onward + hwnd := ctx.(driver.WindowsWindowContext).HWND + myApp.SetupWindowsSMTC(hwnd) + } + } + + // slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 + _, isWayland := ctx.(*driver.WaylandWindowContext) + if runtime.GOOS == "linux" && !isWayland { + go func() { time.Sleep(50 * time.Millisecond) s := mainWindow.DesiredSize() mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0))) time.Sleep(50 * time.Millisecond) mainWindow.Window.Resize(s) // back to desired size - } - }() + }() + } }) - fyneApp.Lifecycle().SetOnEnteredForeground(func() { - workaroundWindowSize() - }) - } + }) + fyneApp.Lifecycle().SetOnEnteredForeground(func() { + startupOnceTasks() + }) mainWindow.ShowAndRun() From 7f3895975ef6b7283c4a56ef6178d2c610f2a704 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 15 Jan 2025 17:49:36 -0800 Subject: [PATCH 2/7] line endings switch, and add missed stubs / go.mod update --- backend/smtc.go | 318 ++++++++++++++++++------------------ backend/smtc_cfuncs.go | 32 ++-- backend/smtc_unsupported.go | 79 +++++---- go.mod | 2 +- 4 files changed, 223 insertions(+), 208 deletions(-) diff --git a/backend/smtc.go b/backend/smtc.go index 6df9069b..f1b56b6d 100644 --- a/backend/smtc.go +++ b/backend/smtc.go @@ -1,159 +1,159 @@ -//go:build windows - -package backend - -/* -#cgo CFLAGS: -I . -void btn_callback_cgo(int in); -void seek_callback_cgo(int in); -*/ -import "C" - -import ( - "errors" - "fmt" - "unsafe" - - "golang.org/x/sys/windows" -) - -type SMTCPlaybackState int -type SMTCButton int - -const ( - // constants from smtc.h in github.com/supersonic-app/smtc-dll - SMTCPlaybackStateStopped SMTCPlaybackState = 2 - SMTCPlaybackStatePlaying SMTCPlaybackState = 3 - SMTCPlaybackStatePaused SMTCPlaybackState = 4 - - SMTCButtonPlay SMTCButton = 0 - SMTCButtonPause SMTCButton = 1 - SMTCButtonStop SMTCButton = 2 - SMTCButtonPrevious SMTCButton = 4 - SMTCButtonNext SMTCButton = 5 -) - -type SMTC struct { - dll *windows.DLL - - onButtonPressed func(SMTCButton) - onSeek func(int) -} - -var smtcInstance *SMTC - -func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { - dll, err := windows.LoadDLL("smtc.dll") - if err != nil { - return nil, err - } - - proc, err := dll.FindProc("InitializeForWindow") - if err != nil { - return nil, err - } - - hr, _, _ := proc.Call(hwnd, uintptr(unsafe.Pointer(C.btn_callback_cgo)), uintptr(unsafe.Pointer(C.seek_callback_cgo))) - if hr < 0 { - return nil, fmt.Errorf("InitializeForWindow failed with HRESULT=%d", hr) - } - - smtcInstance = &SMTC{dll: dll} - return smtcInstance, nil -} - -func (s *SMTC) OnButtonPressed(f func(SMTCButton)) { - s.onButtonPressed = f -} - -func (s *SMTC) OnSeek(f func(millis int)) { - s.onSeek = f -} - -func (s *SMTC) Shutdown() { - if s.dll == nil { - return - } - proc, err := s.dll.FindProc("Destroy") - if err == nil { - proc.Call() - } - - s.dll.Release() - s.dll = nil - smtcInstance = nil -} - -func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { - if s.dll == nil { - return errors.New("SMTC DLL not available") - } - - proc, err := s.dll.FindProc("UpdatePlaybackState") - if err != nil { - return err - } - - if hr, _, _ := proc.Call(uintptr(state)); hr < 0 { - return fmt.Errorf("UpdatePlaybackState failed with HRESULT=%d", hr) - } - return nil -} - -func (s *SMTC) UpdateMetadata(title, artist string) error { - if s.dll == nil { - return errors.New("SMTC DLL not available") - } - - utfTitle, err := windows.UTF16PtrFromString(title) - if err != nil { - return err - } - - utfArtist, err := windows.UTF16PtrFromString(artist) - if err != nil { - return err - } - - proc, err := s.dll.FindProc("UpdateMetadata") - if err != nil { - return err - } - - hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist))) - if hr < 0 { - return fmt.Errorf("UpdateMetadata failed with HRESULT=%d", hr) - } - return nil -} - -func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { - if s.dll == nil { - return errors.New("SMTC DLL not available") - } - - proc, err := s.dll.FindProc("UpdatePosition") - if err != nil { - return err - } - - hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis)) - if hr < 0 { - return fmt.Errorf("UpdatePosition failed with HRESULT=%d", hr) - } - return nil -} - -//export btnCallback -func btnCallback(in int) { - if smtcInstance != nil && smtcInstance.onButtonPressed != nil { - smtcInstance.onButtonPressed(SMTCButton(in)) - } -} - -//export seekCallback -func seekCallback(millis int) { - if smtcInstance != nil && smtcInstance.onSeek != nil { - smtcInstance.onSeek(millis) - } -} +//go:build windows + +package backend + +/* +#cgo CFLAGS: -I . +void btn_callback_cgo(int in); +void seek_callback_cgo(int in); +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +type SMTCPlaybackState int +type SMTCButton int + +const ( + // constants from smtc.h in github.com/supersonic-app/smtc-dll + SMTCPlaybackStateStopped SMTCPlaybackState = 2 + SMTCPlaybackStatePlaying SMTCPlaybackState = 3 + SMTCPlaybackStatePaused SMTCPlaybackState = 4 + + SMTCButtonPlay SMTCButton = 0 + SMTCButtonPause SMTCButton = 1 + SMTCButtonStop SMTCButton = 2 + SMTCButtonPrevious SMTCButton = 4 + SMTCButtonNext SMTCButton = 5 +) + +type SMTC struct { + dll *windows.DLL + + onButtonPressed func(SMTCButton) + onSeek func(int) +} + +var smtcInstance *SMTC + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + dll, err := windows.LoadDLL("smtc.dll") + if err != nil { + return nil, err + } + + proc, err := dll.FindProc("InitializeForWindow") + if err != nil { + return nil, err + } + + hr, _, _ := proc.Call(hwnd, uintptr(unsafe.Pointer(C.btn_callback_cgo)), uintptr(unsafe.Pointer(C.seek_callback_cgo))) + if hr < 0 { + return nil, fmt.Errorf("InitializeForWindow failed with HRESULT=%d", hr) + } + + smtcInstance = &SMTC{dll: dll} + return smtcInstance, nil +} + +func (s *SMTC) OnButtonPressed(f func(SMTCButton)) { + s.onButtonPressed = f +} + +func (s *SMTC) OnSeek(f func(millis int)) { + s.onSeek = f +} + +func (s *SMTC) Shutdown() { + if s.dll == nil { + return + } + proc, err := s.dll.FindProc("Destroy") + if err == nil { + proc.Call() + } + + s.dll.Release() + s.dll = nil + smtcInstance = nil +} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePlaybackState") + if err != nil { + return err + } + + if hr, _, _ := proc.Call(uintptr(state)); hr < 0 { + return fmt.Errorf("UpdatePlaybackState failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + utfTitle, err := windows.UTF16PtrFromString(title) + if err != nil { + return err + } + + utfArtist, err := windows.UTF16PtrFromString(artist) + if err != nil { + return err + } + + proc, err := s.dll.FindProc("UpdateMetadata") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist))) + if hr < 0 { + return fmt.Errorf("UpdateMetadata failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePosition") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis)) + if hr < 0 { + return fmt.Errorf("UpdatePosition failed with HRESULT=%d", hr) + } + return nil +} + +//export btnCallback +func btnCallback(in int) { + if smtcInstance != nil && smtcInstance.onButtonPressed != nil { + smtcInstance.onButtonPressed(SMTCButton(in)) + } +} + +//export seekCallback +func seekCallback(millis int) { + if smtcInstance != nil && smtcInstance.onSeek != nil { + smtcInstance.onSeek(millis) + } +} diff --git a/backend/smtc_cfuncs.go b/backend/smtc_cfuncs.go index 9c1b317c..9ccd9491 100644 --- a/backend/smtc_cfuncs.go +++ b/backend/smtc_cfuncs.go @@ -1,16 +1,16 @@ -//go:build windows - -package backend - -/* -void btn_callback_cgo(int in) { - void btnCallback(int); - btnCallback(in); -} - -void seek_callback_cgo(int in) { - void seekCallback(int); - seekCallback(in); -} -*/ -import "C" +//go:build windows + +package backend + +/* +void btn_callback_cgo(int in) { + void btnCallback(int); + btnCallback(in); +} + +void seek_callback_cgo(int in) { + void seekCallback(int); + seekCallback(in); +} +*/ +import "C" diff --git a/backend/smtc_unsupported.go b/backend/smtc_unsupported.go index 36c0ba19..6a19e8d7 100644 --- a/backend/smtc_unsupported.go +++ b/backend/smtc_unsupported.go @@ -1,32 +1,47 @@ -//go:build !windows - -package backend - -import "errors" - -type SMTCPlaybackState int - -const ( - SMTCPlaybackStateStopped SMTCPlaybackState = 0 - SMTCPlaybackStatePlaying SMTCPlaybackState = 1 - SMTCPlaybackStatePaused SMTCPlaybackState = 2 -) - -type SMTC struct{} - -var smtcUnsupportedErr = errors.New("SMTC is not supported on this platformo") - -func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { - return nil, smtcUnsupportedErr -} - -func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { - return smtcUnsupportedErr -} - -func (s *SMTC) UpdateMetadata(title, artist string) error { - return smtcUnsupportedErr -} - -func (s *SMTC) Shutdown() { -} +//go:build !windows + +package backend + +import "errors" + +type SMTCPlaybackState int +type SMTCButton int + +const ( + // constants from smtc.h in github.com/supersonic-app/smtc-dll + SMTCPlaybackStateStopped SMTCPlaybackState = 2 + SMTCPlaybackStatePlaying SMTCPlaybackState = 3 + SMTCPlaybackStatePaused SMTCPlaybackState = 4 + + SMTCButtonPlay SMTCButton = 0 + SMTCButtonPause SMTCButton = 1 + SMTCButtonStop SMTCButton = 2 + SMTCButtonPrevious SMTCButton = 4 + SMTCButtonNext SMTCButton = 5 +) + +type SMTC struct{} + +var smtcUnsupportedErr = errors.New("SMTC is not supported on this platformo") + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + return nil, smtcUnsupportedErr +} + +func (s *SMTC) OnButtonPressed(func(SMTCButton)) {} + +func (s *SMTC) OnSeek(f func(millis int)) {} + +func (s *SMTC) Shutdown() {} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + return smtcUnsupportedErr +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + return smtcUnsupportedErr +} + +func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { + return smtcUnsupportedErr +} diff --git a/go.mod b/go.mod index e20b4539..ab5a6851 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d github.com/zalando/go-keyring v0.2.1 golang.org/x/net v0.25.0 + golang.org/x/sys v0.20.0 golang.org/x/text v0.16.0 ) @@ -50,7 +51,6 @@ require ( github.com/yuin/goldmark v1.7.1 // indirect golang.org/x/image v0.18.0 // indirect golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect - golang.org/x/sys v0.20.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) From c48eae501baa8b5077c44bb72d32ce96404d2806 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 15 Jan 2025 17:52:50 -0800 Subject: [PATCH 3/7] move Windows version check to smtc.go conditionally compiled for Windows --- backend/smtc.go | 4 ++++ main.go | 9 ++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/smtc.go b/backend/smtc.go index f1b56b6d..095b0841 100644 --- a/backend/smtc.go +++ b/backend/smtc.go @@ -43,6 +43,10 @@ type SMTC struct { var smtcInstance *SMTC func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + if maj, _, _ := windows.RtlGetNtVersionNumbers(); maj < 10 { + return nil, errors.New("SMTC is not supported on Windows versions < 10") + } + dll, err := windows.LoadDLL("smtc.dll") if err != nil { return nil, err diff --git a/main.go b/main.go index c1dc73e8..c1a7451e 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,6 @@ import ( "sync" "time" - "golang.org/x/sys/windows" - "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/ui" @@ -93,11 +91,8 @@ func main() { mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { // intialize Windows SMTC if runtime.GOOS == "windows" { - if maj, _, _ := windows.RtlGetNtVersionNumbers(); maj >= 10 { - // SMTC is only available from Windows 10 (10.0.10240) onward - hwnd := ctx.(driver.WindowsWindowContext).HWND - myApp.SetupWindowsSMTC(hwnd) - } + hwnd := ctx.(driver.WindowsWindowContext).HWND + myApp.SetupWindowsSMTC(hwnd) } // slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 From e465180f3dea26661e7f1829eda0405d4309d54d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 16 Jan 2025 15:56:50 -0800 Subject: [PATCH 4/7] update windows build to include SMTC.dll --- .github/workflows/build-windows.yml | 6 +++++- win_inno_installscript.iss | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index b04b4dc8..a2b61539 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -53,8 +53,12 @@ jobs: rm Supersonic.exe && mv Supersonic-newbuild.exe Supersonic.exe + - name: Download smtc dll + run: > + wget https://github.com/supersonic-app/smtc-dll/releases/download/v0.0.1/SMTC.dll + - name: Generate zip bundle - run: zip Supersonic-windows.zip Supersonic.exe libmpv-2.dll + run: zip Supersonic-windows.zip Supersonic.exe libmpv-2.dll SMTC.dll - name: Generate installer uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 diff --git a/win_inno_installscript.iss b/win_inno_installscript.iss index f5985bd6..9a7cab35 100644 --- a/win_inno_installscript.iss +++ b/win_inno_installscript.iss @@ -46,6 +46,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "libmpv-2.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "SMTC.dll"; DestDir: "{app}"; Flags: ignoreversion ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] From 9fca298a82863c8224cc23110db8a466af1e0e38 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 16 Jan 2025 18:40:06 -0800 Subject: [PATCH 5/7] update for new SMTC dll --- .github/workflows/build-appimage-compat.yml | 2 +- .github/workflows/build-appimage.yml | 2 +- .github/workflows/build-macos-arm64.yml | 2 +- .github/workflows/build-macos-x64.yml | 4 +- .github/workflows/build-ubuntu-22.04.yml | 2 +- .github/workflows/build-ubuntu-24.04.yml | 2 +- .github/workflows/build-windows.yml | 6 +-- backend/app.go | 54 +++++++++++++------- backend/imagemanager.go | 9 ++++ backend/smtc.go | 56 ++++++++++++++++++--- 10 files changed, 105 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build-appimage-compat.yml b/.github/workflows/build-appimage-compat.yml index 0a476e30..3c8577c7 100644 --- a/.github/workflows/build-appimage-compat.yml +++ b/.github/workflows/build-appimage-compat.yml @@ -32,7 +32,7 @@ jobs: ./appimage-build-compat.sh - name: upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic-compat.AppImage path: Supersonic-x86_64.AppImage diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 8b44c7e6..1bc96c2d 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -32,7 +32,7 @@ jobs: ./appimage-build.sh - name: upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic.AppImage path: Supersonic-x86_64.AppImage diff --git a/.github/workflows/build-macos-arm64.yml b/.github/workflows/build-macos-arm64.yml index d18397df..8a017a77 100644 --- a/.github/workflows/build-macos-arm64.yml +++ b/.github/workflows/build-macos-arm64.yml @@ -44,7 +44,7 @@ jobs: make zip_macos - name: Upload package - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic.zip path: Supersonic.zip diff --git a/.github/workflows/build-macos-x64.yml b/.github/workflows/build-macos-x64.yml index bb79ac78..5d97b6d8 100644 --- a/.github/workflows/build-macos-x64.yml +++ b/.github/workflows/build-macos-x64.yml @@ -49,13 +49,13 @@ jobs: mv Supersonic.zip Supersonic_HighSierra+.zip - name: Upload Big Sur+ package - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_mac_x64_BigSur+.zip path: Supersonic_BigSur+.zip - name: Upload High Sierra+ package - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_mac_x64_HighSierra+.zip path: Supersonic_HighSierra+.zip diff --git a/.github/workflows/build-ubuntu-22.04.yml b/.github/workflows/build-ubuntu-22.04.yml index 1b0c4c69..2e4d48b6 100644 --- a/.github/workflows/build-ubuntu-22.04.yml +++ b/.github/workflows/build-ubuntu-22.04.yml @@ -33,7 +33,7 @@ jobs: run: make package_linux - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_ubuntu_x64.tar.xz path: Supersonic.tar.xz diff --git a/.github/workflows/build-ubuntu-24.04.yml b/.github/workflows/build-ubuntu-24.04.yml index c19b6278..b2402e32 100644 --- a/.github/workflows/build-ubuntu-24.04.yml +++ b/.github/workflows/build-ubuntu-24.04.yml @@ -33,7 +33,7 @@ jobs: run: make package_linux - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_ubuntu_x64.tar.xz path: Supersonic.tar.xz diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index a2b61539..bf24cbb1 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -55,7 +55,7 @@ jobs: - name: Download smtc dll run: > - wget https://github.com/supersonic-app/smtc-dll/releases/download/v0.0.1/SMTC.dll + wget https://github.com/supersonic-app/smtc-dll/releases/download/v0.1.0/SMTC.dll - name: Generate zip bundle run: zip Supersonic-windows.zip Supersonic.exe libmpv-2.dll SMTC.dll @@ -66,13 +66,13 @@ jobs: path: win_inno_installscript.iss - name: Upload zip - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_windows_x64.zip path: Supersonic-windows.zip - name: Upload installer - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Supersonic_windows_x64_installer.exe path: Output/supersonic-installer.exe diff --git a/backend/app.go b/backend/app.go index 9a014ab6..620ed2fd 100644 --- a/backend/app.go +++ b/backend/app.go @@ -53,11 +53,12 @@ type App struct { OnReactivate func() OnExit func() - appName string - appVersionTag string - configDir string - cacheDir string - portableMode bool + appName string + displayAppName string + appVersionTag string + configDir string + cacheDir string + portableMode bool isFirstLaunch bool // set by config file reader bgrndCtx context.Context @@ -97,12 +98,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } a := &App{ - logFile: logFile, - appName: appName, - appVersionTag: appVersionTag, - configDir: confDir, - cacheDir: cacheDir, - portableMode: portableMode, + logFile: logFile, + appName: appName, + displayAppName: displayAppName, + appVersionTag: appVersionTag, + configDir: confDir, + cacheDir: cacheDir, + portableMode: portableMode, } a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) a.readConfig() @@ -343,6 +345,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { return } a.WinSMTC = smtc + smtc.UpdateMetadata(a.displayAppName, "") smtc.OnButtonPressed(func(btn SMTCButton) { switch btn { @@ -364,20 +367,35 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { a.PlaybackManager.OnSongChange(func(nowPlaying mediaprovider.MediaItem, _ *mediaprovider.Track) { if nowPlaying == nil { - smtc.UpdateMetadata("", "") + smtc.UpdateMetadata("Supersonic", "") return } - artist := strings.Join(nowPlaying.Metadata().Artists, ", ") - smtc.UpdateMetadata(nowPlaying.Metadata().Name, artist) - smtc.UpdatePosition(0, nowPlaying.Metadata().Duration*1000) + meta := nowPlaying.Metadata() + smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", ")) + smtc.UpdatePosition(0, meta.Duration*1000) + go func() { + a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally + if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil { + smtc.SetThumbnail(path) + } + }() }) a.PlaybackManager.OnSeek(func() { dur := a.PlaybackManager.NowPlaying().Metadata().Duration smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) }) - a.PlaybackManager.OnPlaying(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying) }) - a.PlaybackManager.OnPaused(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePaused) }) - a.PlaybackManager.OnStopped(func() { smtc.UpdatePlaybackState(SMTCPlaybackStateStopped) }) + a.PlaybackManager.OnPlaying(func() { + smtc.SetEnabled(true) + smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying) + }) + a.PlaybackManager.OnPaused(func() { + smtc.SetEnabled(true) + smtc.UpdatePlaybackState(SMTCPlaybackStatePaused) + }) + a.PlaybackManager.OnStopped(func() { + smtc.SetEnabled(false) + smtc.UpdatePlaybackState(SMTCPlaybackStateStopped) + }) } func (a *App) LoginToDefaultServer(string) error { diff --git a/backend/imagemanager.go b/backend/imagemanager.go index b39c7c0d..e824d42b 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -160,6 +160,15 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) { return "", errors.New("cover not found") } +// GetCoverArtURL returns the file path for the locally cached cover thumbnail, if it exists. +func (i *ImageManager) GetCoverArtPath(coverID string) (string, error) { + path := i.filePathForCover(coverID) + if _, err := os.Stat(path); err == nil { + return path, nil + } + return "", errors.New("cover not found") +} + // GetCachedArtistImage returns the artist image for the given artistID from the on-disc cache, if it exists. func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) { return i.loadLocalImage(i.filePathForArtistImage(artistID)) diff --git a/backend/smtc.go b/backend/smtc.go index 095b0841..0c003739 100644 --- a/backend/smtc.go +++ b/backend/smtc.go @@ -93,13 +93,13 @@ func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { return errors.New("SMTC DLL not available") } - proc, err := s.dll.FindProc("UpdatePlaybackState") + proc, err := s.dll.FindProc("SetPlaybackState") if err != nil { return err } if hr, _, _ := proc.Call(uintptr(state)); hr < 0 { - return fmt.Errorf("UpdatePlaybackState failed with HRESULT=%d", hr) + return fmt.Errorf("SetPlaybackState failed with HRESULT=%d", hr) } return nil } @@ -119,14 +119,14 @@ func (s *SMTC) UpdateMetadata(title, artist string) error { return err } - proc, err := s.dll.FindProc("UpdateMetadata") + proc, err := s.dll.FindProc("SetMetadata") if err != nil { return err } hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist))) if hr < 0 { - return fmt.Errorf("UpdateMetadata failed with HRESULT=%d", hr) + return fmt.Errorf("SetMetadata failed with HRESULT=%d", hr) } return nil } @@ -136,14 +136,58 @@ func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { return errors.New("SMTC DLL not available") } - proc, err := s.dll.FindProc("UpdatePosition") + proc, err := s.dll.FindProc("SetPosition") if err != nil { return err } hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis)) if hr < 0 { - return fmt.Errorf("UpdatePosition failed with HRESULT=%d", hr) + return fmt.Errorf("SetPosition failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) SetThumbnail(filepath string) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("SetThumbnailPath") + if err != nil { + return err + } + + utfPath, err := windows.UTF16PtrFromString(filepath) + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfPath))) + if hr < 0 { + return fmt.Errorf("SetThumbnailPath failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) SetEnabled(enabled bool) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("SetEnabled") + if err != nil { + return err + } + + var arg uintptr = 0 + if enabled { + arg = 1 + } + + hr, _, _ := proc.Call(arg) + if hr < 0 { + return fmt.Errorf("SetEnabled failed with HRESULT=%d", hr) } return nil } From fd04998a5a905c18b02ed6a2810290cc09e387b9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 16 Jan 2025 18:42:03 -0800 Subject: [PATCH 6/7] add missed stubs --- backend/smtc_unsupported.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/smtc_unsupported.go b/backend/smtc_unsupported.go index 6a19e8d7..18cf09e7 100644 --- a/backend/smtc_unsupported.go +++ b/backend/smtc_unsupported.go @@ -28,6 +28,14 @@ func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { return nil, smtcUnsupportedErr } +func (s *SMTC) SetEnabled(enabled bool) error { + return smtcUnsupportedErr +} + +func (s *SMTC) SetThumbnail(filepath string) error { + return smtcUnsupportedErr +} + func (s *SMTC) OnButtonPressed(func(SMTCButton)) {} func (s *SMTC) OnSeek(f func(millis int)) {} From d1b83c59a12ce9ccd608436e7c2b705468d56961 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Feb 2025 17:07:16 -0300 Subject: [PATCH 7/7] sanitize IDs before using as file names --- backend/imagemanager.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/imagemanager.go b/backend/imagemanager.go index e824d42b..5f99b5ce 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -12,6 +12,7 @@ import ( "os" "path" "path/filepath" + "regexp" "sort" "strings" "time" @@ -160,7 +161,7 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) { return "", errors.New("cover not found") } -// GetCoverArtURL returns the file path for the locally cached cover thumbnail, if it exists. +// GetCoverArtPath returns the file path for the locally cached cover thumbnail, if it exists. func (i *ImageManager) GetCoverArtPath(coverID string) (string, error) { path := i.filePathForCover(coverID) if _, err := os.Stat(path); err == nil { @@ -315,11 +316,11 @@ func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, coverID string, } func (i *ImageManager) filePathForCover(coverID string) string { - return filepath.Join(i.ensureCoverCacheDir(), fmt.Sprintf("%s.jpg", coverID)) + return filepath.Join(i.ensureCoverCacheDir(), fmt.Sprintf("%s.jpg", sanitizeFileName(coverID))) } func (i *ImageManager) filePathForArtistImage(id string) string { - return filepath.Join(i.ensureArtistCoverCacheDir(), fmt.Sprintf("%s.jpg", id)) + return filepath.Join(i.ensureArtistCoverCacheDir(), fmt.Sprintf("%s.jpg", sanitizeFileName(id))) } func (i *ImageManager) writeJpeg(img image.Image, path string) error { @@ -400,3 +401,9 @@ func (im *ImageManager) pruneOnDiskCache() { } im.filesWrittenSinceLastPrune = false } + +var illegalFilename = regexp.MustCompile("[" + regexp.QuoteMeta("`~!@#$%^&*+={}|/\\:;\"'<>?") + "]") + +func sanitizeFileName(s string) string { + return illegalFilename.ReplaceAllString(s, "_") +}