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 b04b4dc8..bf24cbb1 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.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 diff --git a/backend/app.go b/backend/app.go index 06f1fc96..620ed2fd 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,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() diff --git a/backend/imagemanager.go b/backend/imagemanager.go index b39c7c0d..5f99b5ce 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -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, "_") +} diff --git a/backend/smtc.go b/backend/smtc.go new file mode 100644 index 00000000..0c003739 --- /dev/null +++ b/backend/smtc.go @@ -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) + } +} diff --git a/backend/smtc_cfuncs.go b/backend/smtc_cfuncs.go new file mode 100644 index 00000000..9ccd9491 --- /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..18cf09e7 --- /dev/null +++ b/backend/smtc_unsupported.go @@ -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 +} diff --git a/go.mod b/go.mod index 8a2ee3a4..bbe92f33 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.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 ) diff --git a/main.go b/main.go index d41eedfc..c1a7451e 100644 --- a/main.go +++ b/main.go @@ -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() 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]