From f48b2ba21057dc331b08df4687235bf9cbfc4036 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 8 Nov 2025 08:16:48 -0800 Subject: [PATCH] Fix #660: add cover art, silent property to Windows track change notification --- backend/windows/notify.go | 75 +++++++++++++++++++++++++++ backend/windows/notify_unsupported.go | 10 ++++ ui/mainwindow.go | 20 ++++++- 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 backend/windows/notify.go create mode 100644 backend/windows/notify_unsupported.go diff --git a/backend/windows/notify.go b/backend/windows/notify.go new file mode 100644 index 0000000..d9ff896 --- /dev/null +++ b/backend/windows/notify.go @@ -0,0 +1,75 @@ +//go:build windows + +package windows + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "fyne.io/fyne/v2" +) + +// The general approach here is copied from Fyne. +// While it seems very hacky (create a temporary Powershell script and execute it), +// shockingly it may be the best approach, at least in the non-installed case. +// The proper Windows APIs for this require WinRT (ie C++/ a DLL), and also require +// the app to be installed with a unique ID in the start menu, and to pass this ID +// when sending the notification. This could be a future exploration for the installer. + +const notificationTemplate = `$title = "%s" +$content = "%s" +$iconPath = "file:///%s" +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null +$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02) +$toastXml = [xml] $template.GetXml() +$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null +$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null +$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null +$audio = $toastXml.CreateElement("audio") +$audio.SetAttribute("silent", "true") > $null +$toastXml.DocumentElement.AppendChild($audio) > $null +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($toastXml.OuterXml) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("%s").Show($toast);` + +func SendNotification(n *fyne.Notification, iconFilePath string) { + title := escapeNotificationString(n.Title) + content := escapeNotificationString(n.Content) + + script := fmt.Sprintf(notificationTemplate, title, content, iconFilePath, "supersonic") + go runScript("notify", script) +} + +func escapeNotificationString(in string) string { + noSlash := strings.ReplaceAll(in, "`", "``") + return strings.ReplaceAll(noSlash, "\"", "`\"") +} + +var scriptNum = 0 + +func runScript(name, script string) { + scriptNum++ + appID := fyne.CurrentApp().UniqueID() + fileName := fmt.Sprintf("supersonic-%s-%s-%d.ps1", appID, name, scriptNum) + + tmpFilePath := filepath.Join(os.TempDir(), fileName) + err := os.WriteFile(tmpFilePath, []byte(script), 0o600) + if err != nil { + fyne.LogError("Could not write script to show notification", err) + return + } + defer os.Remove(tmpFilePath) + + launch := "(Get-Content -Encoding UTF8 -Path " + tmpFilePath + " -Raw) | Invoke-Expression" + cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", launch) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + err = cmd.Run() + if err != nil { + fyne.LogError("Failed to launch windows notify script", err) + } +} diff --git a/backend/windows/notify_unsupported.go b/backend/windows/notify_unsupported.go new file mode 100644 index 0000000..f5864e9 --- /dev/null +++ b/backend/windows/notify_unsupported.go @@ -0,0 +1,10 @@ +//go:build !windows + +package windows + +import "fyne.io/fyne/v2" + +func SendNotification(n *fyne.Notification, iconFilePath string) { + fyne.LogError("windows.SendNotification should not be invoked on non-Windows platform", nil) + fyne.CurrentApp().SendNotification(n) +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 9cf3193..1f5dfba 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -11,6 +11,7 @@ import ( fynetooltip "github.com/dweymouth/fyne-tooltip" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/windows" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/ui/browsing" uicontainer "github.com/dweymouth/supersonic/ui/container" @@ -182,7 +183,8 @@ func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) { } m.Window.SetTitle(fmt.Sprintf("%s%s ยท %s", meta.Name, artistDisp, res.DisplayName)) if m.App.Config.Application.ShowTrackChangeNotification { - if runtime.GOOS == "linux" { + switch runtime.GOOS { + case "linux": if notifySend, err := exec.LookPath("notify-send"); err == nil { go func() { args := []string{ @@ -205,9 +207,23 @@ func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) { }() return } + case "windows": + go func() { + // ensure cover thumbnail is cached locally + m.App.ImageManager.GetCoverThumbnail(meta.CoverArtID) + path, _ := m.App.ImageManager.GetCoverArtPath(meta.CoverArtID) + fyne.Do(func() { + windows.SendNotification(&fyne.Notification{ + Title: meta.Name, + Content: artistDisp, + }, path) + }) + }() + return } - // TODO: Once Fyne issue #2935 is resolved, show album cover + // fallback for not handled above + // TODO: Once Fyne issue #2935 is resolved, show album cover on other platforms fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: meta.Name, Content: artistDisp,