Merge pull request #533 from dweymouth/feature/windows-smtc

Windows SMTC (System Media Transport Controls) integration
This commit is contained in:
Drew Weymouth
2025-02-01 17:15:48 -03:00
committed by GitHub
15 changed files with 411 additions and 39 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7 -3
View File
@@ -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.1.0/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
@@ -62,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
+81 -11
View File
@@ -12,6 +12,7 @@ import (
"reflect"
"runtime"
"slices"
"strings"
"time"
"github.com/dweymouth/supersonic/backend/ipc"
@@ -45,17 +46,19 @@ type App struct {
LocalPlayer *mpv.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
WinSMTC *SMTC
ipcServer ipc.IPCServer
// UI callbacks to be set in main
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
@@ -95,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()
@@ -165,11 +169,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 +338,66 @@ 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.UpdateMetadata(a.displayAppName, "")
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("Supersonic", "")
return
}
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.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 {
serverCfg := a.ServerManager.GetDefaultServer()
if serverCfg == nil {
@@ -370,6 +437,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()
+18 -2
View File
@@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@@ -160,6 +161,15 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) {
return "", errors.New("cover not found")
}
// 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 {
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))
@@ -306,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 {
@@ -391,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, "_")
}
+207
View File
@@ -0,0 +1,207 @@
//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) {
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
}
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("SetPlaybackState")
if err != nil {
return err
}
if hr, _, _ := proc.Call(uintptr(state)); hr < 0 {
return fmt.Errorf("SetPlaybackState 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("SetMetadata")
if err != nil {
return err
}
hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist)))
if hr < 0 {
return fmt.Errorf("SetMetadata 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("SetPosition")
if err != nil {
return err
}
hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis))
if hr < 0 {
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
}
//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)
}
}
+16
View File
@@ -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"
+55
View File
@@ -0,0 +1,55 @@
//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) 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)) {}
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
}
+1 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/zalando/go-keyring v0.2.6
golang.org/x/net v0.25.0
golang.org/x/sys v0.26.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.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+18 -15
View File
@@ -87,27 +87,30 @@ 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" {
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()
+1
View File
@@ -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]