From c80f4399eb12d28c9826c36464184a0aa69c69ac Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 20 Jul 2026 18:18:17 -0700 Subject: [PATCH] Add playback controls to dock menu on MacOS --- ui/app_delegate_darwin.go | 43 ++++++++++++++++++++++++++- ui/app_delegate_darwin.m | 51 ++++++++++++++++++++++++++++++++ ui/app_delegate_other.go | 3 ++ ui/mainwindow.go | 61 ++++++++++++++++++++++----------------- 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/ui/app_delegate_darwin.go b/ui/app_delegate_darwin.go index 7b430a8..467b7e1 100644 --- a/ui/app_delegate_darwin.go +++ b/ui/app_delegate_darwin.go @@ -3,11 +3,20 @@ package ui /* +#include void installReopenDelegate(); +void dockMenuBegin(); +void dockMenuAddItem(const char* title, int index); +void dockMenuAddSeparator(); +void dockMenuCommit(); */ import "C" -import "fyne.io/fyne/v2" +import ( + "unsafe" + + "fyne.io/fyne/v2" +) var darwinAppDelegateReopenWindow fyne.Window @@ -38,3 +47,35 @@ func appReopened() { func appShouldTerminate() { darwinQuitting = true } + +// darwinDockMenuCallbacks is indexed by the NSMenuItem tag set in +// dockMenuAddItem, dispatched back to Go via dockMenuItemClicked. +var darwinDockMenuCallbacks []func() + +func installDockMenu(menu *fyne.Menu) { + darwinDockMenuCallbacks = darwinDockMenuCallbacks[:0] + + C.dockMenuBegin() + for _, item := range menu.Items { + if item.IsSeparator { + C.dockMenuAddSeparator() + continue + } + cTitle := C.CString(item.Label) + C.dockMenuAddItem(cTitle, C.int(len(darwinDockMenuCallbacks))) + C.free(unsafe.Pointer(cTitle)) + darwinDockMenuCallbacks = append(darwinDockMenuCallbacks, item.Action) + } + C.dockMenuCommit() +} + +//export dockMenuItemClicked +func dockMenuItemClicked(index C.int) { + i := int(index) + if i < 0 || i >= len(darwinDockMenuCallbacks) { + return + } + if cb := darwinDockMenuCallbacks[i]; cb != nil { + go func() { fyne.Do(cb) }() + } +} diff --git a/ui/app_delegate_darwin.m b/ui/app_delegate_darwin.m index 67621ce..e1146fd 100644 --- a/ui/app_delegate_darwin.m +++ b/ui/app_delegate_darwin.m @@ -4,6 +4,28 @@ extern void appReopened(void); extern void appShouldTerminate(void); +extern void dockMenuItemClicked(int index); + +// Generic action target for Dock menu items - the menu contents (titles, +// separators, count) are fully defined from the Go side. Each item's tag +// is the index of its Go-side callback, dispatched through the single +// dockMenuItemClicked export. +@interface DockMenuTarget : NSObject +- (void)itemClicked:(id)sender; +@end + +@implementation DockMenuTarget +- (void)itemClicked:(id)sender { + dockMenuItemClicked((int)[(NSMenuItem *)sender tag]); +} +@end + +// dockMenuBuilding is assembled by dockMenuBegin/AddItem/AddSeparator and +// published to dockMenu (returned from -applicationDockMenu:) by +// dockMenuCommit, so an in-progress rebuild is never seen half-built. +static DockMenuTarget *dockMenuTarget; +static NSMenu *dockMenuBuilding; +static NSMenu *dockMenu; @interface ReopenDelegate : NSObject @property (nonatomic, strong) id wrapped; @@ -34,6 +56,10 @@ extern void appShouldTerminate(void); return YES; } +- (NSMenu *)applicationDockMenu:(NSApplication *)sender { + return dockMenu; +} + - (BOOL)respondsToSelector:(SEL)sel { return [super respondsToSelector:sel] || [self.wrapped respondsToSelector:sel]; @@ -50,3 +76,28 @@ void installReopenDelegate(void) { d.wrapped = [NSApp delegate]; [NSApp setDelegate:d]; } + +void dockMenuBegin(void) { + if (!dockMenuTarget) { + dockMenuTarget = [DockMenuTarget new]; + } + dockMenuBuilding = [[NSMenu alloc] initWithTitle:@""]; +} + +void dockMenuAddItem(const char *title, int index) { + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:title] + action:@selector(itemClicked:) + keyEquivalent:@""]; + item.target = dockMenuTarget; + item.tag = index; + [dockMenuBuilding addItem:item]; +} + +void dockMenuAddSeparator(void) { + [dockMenuBuilding addItem:[NSMenuItem separatorItem]]; +} + +void dockMenuCommit(void) { + dockMenu = dockMenuBuilding; + dockMenuBuilding = nil; +} diff --git a/ui/app_delegate_other.go b/ui/app_delegate_other.go index c48ba75..d8d816a 100644 --- a/ui/app_delegate_other.go +++ b/ui/app_delegate_other.go @@ -10,3 +10,6 @@ func installReopenHandler(w fyne.Window) { func isRealQuit() bool { return false } + +func installDockMenu(menu *fyne.Menu) { +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 72a61e4..b160495 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -62,6 +62,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, theme: myTheme.NewMyTheme(&app.Config.Theme, app.ThemesDir()), } installReopenHandler(m.Window) + installDockMenu(m.createSystemTrayAndDockMenu(false)) fynetooltip.SetToolTipTextSizeName(myTheme.SizeNameSubText) m.theme.NormalFont = app.Config.Application.FontNormalTTF @@ -405,33 +406,7 @@ func (m *MainWindow) RunOnServerConnectedTasks(serverConf *backend.ServerConfig, func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) { if desk, ok := fyneApp.(desktop.App); ok { - menu := fyne.NewMenu(appName, - fyne.NewMenuItem(fmt.Sprintf("%s/%s", lang.L("Play"), lang.L("Pause")), func() { - m.App.PlaybackManager.PlayPause() - }), - fyne.NewMenuItem(lang.L("Previous"), func() { - m.App.PlaybackManager.SeekBackOrPrevious() - }), - fyne.NewMenuItem(lang.L("Next"), func() { - m.App.PlaybackManager.SeekNext() - }), - fyne.NewMenuItemSeparator(), - fyne.NewMenuItem(lang.L("Volume")+" +10%", func() { - vol := m.App.PlaybackManager.Volume() - vol = vol + int(float64(vol)*0.1) - // will clamp to range for us - m.App.PlaybackManager.SetVolume(vol) - }), - fyne.NewMenuItem(lang.L("Volume")+" -10%", func() { - vol := m.App.PlaybackManager.Volume() - vol = vol - int(float64(vol)*0.1) - m.App.PlaybackManager.SetVolume(vol) - }), - fyne.NewMenuItemSeparator(), - fyne.NewMenuItem(lang.L("Show"), m.Window.Show), - fyne.NewMenuItem(lang.L("Hide"), m.Window.Hide), - ) - desk.SetSystemTrayMenu(menu) + desk.SetSystemTrayMenu(m.createSystemTrayAndDockMenu(true)) desk.SetSystemTrayIcon(res.ResAppicon256Png) if runtime.GOOS != "darwin" { // Left-click opening systray menu instead of raising window @@ -442,6 +417,38 @@ func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) { } } +func (m *MainWindow) createSystemTrayAndDockMenu(includeShowAndHide bool) *fyne.Menu { + menu := fyne.NewMenu("", + fyne.NewMenuItem(fmt.Sprintf("%s/%s", lang.L("Play"), lang.L("Pause")), func() { + m.App.PlaybackManager.PlayPause() + }), + fyne.NewMenuItem(lang.L("Previous"), func() { + m.App.PlaybackManager.SeekBackOrPrevious() + }), + fyne.NewMenuItem(lang.L("Next"), func() { + m.App.PlaybackManager.SeekNext() + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem(lang.L("Volume")+" +10%", func() { + vol := m.App.PlaybackManager.Volume() + vol = vol + int(float64(vol)*0.1) + // will clamp to range for us + m.App.PlaybackManager.SetVolume(vol) + }), + fyne.NewMenuItem(lang.L("Volume")+" -10%", func() { + vol := m.App.PlaybackManager.Volume() + vol = vol - int(float64(vol)*0.1) + m.App.PlaybackManager.SetVolume(vol) + }), + ) + if includeShowAndHide { + menu.Items = append(menu.Items, fyne.NewMenuItemSeparator()) + menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Show"), m.Window.Show)) + menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Hide"), m.Window.Hide)) + } + return menu +} + func (m *MainWindow) HaveSystemTray() bool { return m.haveSystemTray }