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 ./appimage-build-compat.sh
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic-compat.AppImage name: Supersonic-compat.AppImage
path: Supersonic-x86_64.AppImage path: Supersonic-x86_64.AppImage
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
./appimage-build.sh ./appimage-build.sh
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic.AppImage name: Supersonic.AppImage
path: Supersonic-x86_64.AppImage path: Supersonic-x86_64.AppImage
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
make zip_macos make zip_macos
- name: Upload package - name: Upload package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic.zip name: Supersonic.zip
path: Supersonic.zip path: Supersonic.zip
+2 -2
View File
@@ -49,13 +49,13 @@ jobs:
mv Supersonic.zip Supersonic_HighSierra+.zip mv Supersonic.zip Supersonic_HighSierra+.zip
- name: Upload Big Sur+ package - name: Upload Big Sur+ package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_mac_x64_BigSur+.zip name: Supersonic_mac_x64_BigSur+.zip
path: Supersonic_BigSur+.zip path: Supersonic_BigSur+.zip
- name: Upload High Sierra+ package - name: Upload High Sierra+ package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_mac_x64_HighSierra+.zip name: Supersonic_mac_x64_HighSierra+.zip
path: Supersonic_HighSierra+.zip path: Supersonic_HighSierra+.zip
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
run: make package_linux run: make package_linux
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_ubuntu_x64.tar.xz name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz path: Supersonic.tar.xz
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
run: make package_linux run: make package_linux
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_ubuntu_x64.tar.xz name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz path: Supersonic.tar.xz
+7 -3
View File
@@ -53,8 +53,12 @@ jobs:
rm Supersonic.exe && rm Supersonic.exe &&
mv Supersonic-newbuild.exe 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 - 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 - name: Generate installer
uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 uses: Minionguyjpro/Inno-Setup-Action@v1.2.2
@@ -62,13 +66,13 @@ jobs:
path: win_inno_installscript.iss path: win_inno_installscript.iss
- name: Upload zip - name: Upload zip
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_windows_x64.zip name: Supersonic_windows_x64.zip
path: Supersonic-windows.zip path: Supersonic-windows.zip
- name: Upload installer - name: Upload installer
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_windows_x64_installer.exe name: Supersonic_windows_x64_installer.exe
path: Output/supersonic-installer.exe path: Output/supersonic-installer.exe
+81 -11
View File
@@ -12,6 +12,7 @@ import (
"reflect" "reflect"
"runtime" "runtime"
"slices" "slices"
"strings"
"time" "time"
"github.com/dweymouth/supersonic/backend/ipc" "github.com/dweymouth/supersonic/backend/ipc"
@@ -45,17 +46,19 @@ type App struct {
LocalPlayer *mpv.Player LocalPlayer *mpv.Player
UpdateChecker UpdateChecker UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler MPRISHandler *MPRISHandler
WinSMTC *SMTC
ipcServer ipc.IPCServer ipcServer ipc.IPCServer
// UI callbacks to be set in main // UI callbacks to be set in main
OnReactivate func() OnReactivate func()
OnExit func() OnExit func()
appName string appName string
appVersionTag string displayAppName string
configDir string appVersionTag string
cacheDir string configDir string
portableMode bool cacheDir string
portableMode bool
isFirstLaunch bool // set by config file reader isFirstLaunch bool // set by config file reader
bgrndCtx context.Context bgrndCtx context.Context
@@ -95,12 +98,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
} }
a := &App{ a := &App{
logFile: logFile, logFile: logFile,
appName: appName, appName: appName,
appVersionTag: appVersionTag, displayAppName: displayAppName,
configDir: confDir, appVersionTag: appVersionTag,
cacheDir: cacheDir, configDir: confDir,
portableMode: portableMode, cacheDir: cacheDir,
portableMode: portableMode,
} }
a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) a.bgrndCtx, a.cancel = context.WithCancel(context.Background())
a.readConfig() a.readConfig()
@@ -165,11 +169,14 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
} }
// OS media center integrations // OS media center integrations
// Linux MPRIS
a.setupMPRIS(displayAppName) a.setupMPRIS(displayAppName)
// MacOS MPNowPlayingInfoCenter
InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) { InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) {
a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally
return a.ImageManager.GetCoverArtUrl(id) return a.ImageManager.GetCoverArtUrl(id)
}) })
// Windows SMTC is initialized from main once we have a window HWND.
a.startConfigWriter(a.bgrndCtx) a.startConfigWriter(a.bgrndCtx)
@@ -331,6 +338,66 @@ func (a *App) setupMPRIS(mprisAppName string) {
a.MPRISHandler.Start() 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 { func (a *App) LoginToDefaultServer(string) error {
serverCfg := a.ServerManager.GetDefaultServer() serverCfg := a.ServerManager.GetDefaultServer()
if serverCfg == nil { if serverCfg == nil {
@@ -370,6 +437,9 @@ func (a *App) Shutdown() {
a.ipcServer.Shutdown(a.bgrndCtx) a.ipcServer.Shutdown(a.bgrndCtx)
} }
a.MPRISHandler.Shutdown() a.MPRISHandler.Shutdown()
if a.WinSMTC != nil {
a.WinSMTC.Shutdown()
}
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Stop() // will trigger scrobble check a.PlaybackManager.Stop() // will trigger scrobble check
a.cancel() a.cancel()
+18 -2
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -160,6 +161,15 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) {
return "", errors.New("cover not found") 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. // 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) { func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) {
return i.loadLocalImage(i.filePathForArtistImage(artistID)) 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 { 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 { 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 { func (i *ImageManager) writeJpeg(img image.Image, path string) error {
@@ -391,3 +401,9 @@ func (im *ImageManager) pruneOnDiskCache() {
} }
im.filesWrittenSinceLastPrune = false 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/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/zalando/go-keyring v0.2.6 github.com/zalando/go-keyring v0.2.6
golang.org/x/net v0.25.0 golang.org/x/net v0.25.0
golang.org/x/sys v0.26.0
golang.org/x/text v0.16.0 golang.org/x/text v0.16.0
) )
@@ -50,7 +51,6 @@ require (
github.com/yuin/goldmark v1.7.1 // indirect github.com/yuin/goldmark v1.7.1 // indirect
golang.org/x/image v0.18.0 // indirect golang.org/x/image v0.18.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // 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 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 startupOnceTasks := sync.OnceFunc(func() {
if runtime.GOOS == "linux" { mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) {
workaroundWindowSize := sync.OnceFunc(func() { // intialize Windows SMTC
go func() { if runtime.GOOS == "windows" {
isWayland := false hwnd := ctx.(driver.WindowsWindowContext).HWND
mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { myApp.SetupWindowsSMTC(hwnd)
_, isWayland = ctx.(*driver.WaylandWindowContext) }
})
if !isWayland { // 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) time.Sleep(50 * time.Millisecond)
s := mainWindow.DesiredSize() s := mainWindow.DesiredSize()
mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0))) mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0)))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
mainWindow.Window.Resize(s) // back to desired size mainWindow.Window.Resize(s) // back to desired size
} }()
}() }
}) })
fyneApp.Lifecycle().SetOnEnteredForeground(func() { })
workaroundWindowSize() fyneApp.Lifecycle().SetOnEnteredForeground(func() {
}) startupOnceTasks()
} })
mainWindow.ShowAndRun() mainWindow.ShowAndRun()
+1
View File
@@ -46,6 +46,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
[Files] [Files]
Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "libmpv-2.dll"; 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 ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons] [Icons]