From f59aebd2f84bebe6113e3861c3be523bc8d48793 Mon Sep 17 00:00:00 2001 From: aynakeya Date: Sun, 19 Oct 2025 01:12:19 +0800 Subject: [PATCH] add macos system media control --- app/main.go | 1 + gui/gctx/context.go | 3 +- gui/views/player/player.go | 2 +- gui/views/systray/tray.go | 6 +- internal/sysmediacontrol/smc_darwin.go | 261 ++++++++++++++++++++++++- 5 files changed, 266 insertions(+), 7 deletions(-) diff --git a/app/main.go b/app/main.go index b36ae98..9f46975 100644 --- a/app/main.go +++ b/app/main.go @@ -11,6 +11,7 @@ import ( "AynaLivePlayer/pkg/eventbus" "AynaLivePlayer/pkg/i18n" "AynaLivePlayer/pkg/logger" + loggerRepo "AynaLivePlayer/pkg/logger/repository" "flag" "os" diff --git a/gui/gctx/context.go b/gui/gctx/context.go index 1723a16..db54162 100644 --- a/gui/gctx/context.go +++ b/gui/gctx/context.go @@ -32,7 +32,8 @@ func NewGuiContext(app fyne.App, mainWindow fyne.Window) *GuiContext { func (c *GuiContext) Init() { c.Window.SetOnClosed(func() { - for _, f := range c.onMainWindowClosing { + for idx, f := range c.onMainWindowClosing { + Logger.Debugf("runing gui closing handler #%d", idx) f() } }) diff --git a/gui/views/player/player.go b/gui/views/player/player.go index f0e8fc6..dd8ae72 100644 --- a/gui/views/player/player.go +++ b/gui/views/player/player.go @@ -12,7 +12,7 @@ func CreateView() fyne.CanvasObject { gctx.Context.OnMainWindowClosing(func() { if playerWindow != nil { gctx.Logger.Infof("closing player window") - playerWindow.Close() + go playerWindow.Close() } }) return container.NewBorder(nil, createPlayControllerV2(), nil, nil, createPlaylist()) diff --git a/gui/views/systray/tray.go b/gui/views/systray/tray.go index 552ac0e..7b73d98 100644 --- a/gui/views/systray/tray.go +++ b/gui/views/systray/tray.go @@ -16,8 +16,8 @@ func SetupSysTray() { })) desk.SetSystemTrayMenu(m) desk.SetSystemTrayIcon(resource.ImageIcon) + gctx.Context.Window.SetCloseIntercept(func() { + gctx.Context.Window.Hide() + }) } - gctx.Context.Window.SetCloseIntercept(func() { - gctx.Context.Window.Hide() - }) } diff --git a/internal/sysmediacontrol/smc_darwin.go b/internal/sysmediacontrol/smc_darwin.go index 7cbfcd5..35fb7ce 100644 --- a/internal/sysmediacontrol/smc_darwin.go +++ b/internal/sysmediacontrol/smc_darwin.go @@ -1,9 +1,266 @@ +//go:build darwin + package sysmediacontrol +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework MediaPlayer -framework AppKit + +#import +#import +#import + +// Forward declaration for Go export function +extern void handleCommand(int); + +// Command handler +static MPRemoteCommandHandlerStatus commandHandler(MPRemoteCommandEvent *event, int command) { + handleCommand(command); + return MPRemoteCommandHandlerStatusSuccess; +} + +// Initialize media player controls +static void initMediaPlayer() { + @autoreleasepool { + MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + + [[commandCenter playCommand] addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { + return commandHandler(event, 0); // 0 = play + }]; + + [[commandCenter pauseCommand] addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { + return commandHandler(event, 1); // 1 = pause + }]; + + [[commandCenter nextTrackCommand] addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { + return commandHandler(event, 2); // 2 = next + }]; + + [[commandCenter previousTrackCommand] addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { + return commandHandler(event, 3); // 3 = previous + }]; + + // Enable commands + [commandCenter playCommand].enabled = YES; + [commandCenter pauseCommand].enabled = YES; + [commandCenter nextTrackCommand].enabled = YES; + [commandCenter previousTrackCommand].enabled = YES; + } +} + +// Update now playing info +static void updateNowPlaying(const char *title, const char *artist, const char *album, + double duration, double position, int isPlaying) { + @autoreleasepool { + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; + if (nowPlayingInfo == nil) { + nowPlayingInfo = [NSMutableDictionary dictionary]; + } + + if (title != NULL) { + [nowPlayingInfo setObject:[NSString stringWithUTF8String:title] + forKey:MPMediaItemPropertyTitle]; + } + + if (artist != NULL) { + [nowPlayingInfo setObject:[NSString stringWithUTF8String:artist] + forKey:MPMediaItemPropertyArtist]; + } + + if (album != NULL) { + [nowPlayingInfo setObject:[NSString stringWithUTF8String:album] + forKey:MPMediaItemPropertyAlbumTitle]; + } + + if (duration > 0) { + [nowPlayingInfo setObject:[NSNumber numberWithDouble:duration] + forKey:MPMediaItemPropertyPlaybackDuration]; + } + + if (position >= 0) { + [nowPlayingInfo setObject:[NSNumber numberWithDouble:position] + forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime]; + } + + [nowPlayingInfo setObject:[NSNumber numberWithDouble:(isPlaying ? 1.0 : 0.0)] + forKey:MPNowPlayingInfoPropertyPlaybackRate]; + + center.nowPlayingInfo = nowPlayingInfo; + } +} + +// Update artwork from URL +static void updateArtworkFromURL(const char *urlString) { + @autoreleasepool { + if (urlString == NULL) return; + + NSString *urlStr = [NSString stringWithUTF8String:urlString]; + NSURL *url = [NSURL URLWithString:urlStr]; + if (url == NULL) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *imageData = [NSData dataWithContentsOfURL:url]; + if (imageData) { + NSImage *image = [[NSImage alloc] initWithData:imageData]; + if (image) { + MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] + initWithBoundsSize:image.size + requestHandler:^NSImage * _Nonnull(CGSize size) { + return image; + }]; + + dispatch_async(dispatch_get_main_queue(), ^{ + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; + if (nowPlayingInfo == nil) { + nowPlayingInfo = [NSMutableDictionary dictionary]; + } + [nowPlayingInfo setObject:artwork forKey:MPMediaItemPropertyArtwork]; + center.nowPlayingInfo = nowPlayingInfo; + }); + } + } + }); + } +} + +// Clear now playing info +static void clearNowPlaying() { + @autoreleasepool { + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + center.nowPlayingInfo = nil; + } +} +*/ +import "C" +import ( + "AynaLivePlayer/core/events" + "AynaLivePlayer/global" + "AynaLivePlayer/pkg/eventbus" + "AynaLivePlayer/pkg/logger" + "unsafe" +) + +var ( + log logger.ILogger + currentTitle string + currentArtist string + currentAlbum string + currentDuration float64 + currentPosition float64 + currentIsPlaying bool +) + +//export handleCommand +func handleCommand(command C.int) { + switch command { + case 0: // Play + _ = global.EventBus.Publish( + events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: false}) + case 1: // Pause + _ = global.EventBus.Publish( + events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: true}) + case 2: // Next + _ = global.EventBus.Publish( + events.PlayerPlayNextCmd, events.PlayerPlayNextCmdEvent{}) + case 3: // Previous + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: 0, + Absolute: true, + }) + } +} + +func updateNowPlayingInfo() { + titleC := C.CString(currentTitle) + artistC := C.CString(currentArtist) + albumC := C.CString(currentAlbum) + defer C.free(unsafe.Pointer(titleC)) + defer C.free(unsafe.Pointer(artistC)) + defer C.free(unsafe.Pointer(albumC)) + + isPlaying := 0 + if currentIsPlaying { + isPlaying = 1 + } + + C.updateNowPlaying( + titleC, + artistC, + albumC, + C.double(currentDuration), + C.double(currentPosition), + C.int(isPlaying), + ) +} + func InitSystemMediaControl() { - // stub + log = global.Logger.WithPrefix("SMTC-Darwin") + + // Initialize media player controls + C.initMediaPlayer() + + // Subscribe to player playing update events + global.EventBus.Subscribe("", events.PlayerPlayingUpdate, "sysmediacontrol.update_playing", func(event *eventbus.Event) { + data := event.Data.(events.PlayerPlayingUpdateEvent) + + if data.Removed { + C.clearNowPlaying() + currentTitle = "" + currentArtist = "" + currentAlbum = "" + currentDuration = 0 + currentPosition = 0 + return + } + + currentTitle = data.Media.Info.Title + currentArtist = data.Media.Info.Artist + currentAlbum = data.Media.Info.Album + + updateNowPlayingInfo() + + // Update artwork if available + if data.Media.Info.Cover.Url != "" { + urlC := C.CString(data.Media.Info.Cover.Url) + C.updateArtworkFromURL(urlC) + C.free(unsafe.Pointer(urlC)) + } + }) + + // Subscribe to pause state updates + global.EventBus.Subscribe("", events.PlayerPropertyPauseUpdate, "sysmediacontrol.update_paused", func(event *eventbus.Event) { + data := event.Data.(events.PlayerPropertyPauseUpdateEvent) + currentIsPlaying = !data.Paused + updateNowPlayingInfo() + }) + + // Subscribe to duration updates + global.EventBus.Subscribe("", events.PlayerPropertyDurationUpdate, "sysmediacontrol.properties.duration", func(event *eventbus.Event) { + data := event.Data.(events.PlayerPropertyDurationUpdateEvent) + currentDuration = data.Duration + updateNowPlayingInfo() + }) + + // Subscribe to time position updates + global.EventBus.Subscribe("", events.PlayerPropertyTimePosUpdate, "sysmediacontrol.properties.time_pos", func(event *eventbus.Event) { + data := event.Data.(events.PlayerPropertyTimePosUpdateEvent) + currentPosition = data.TimePos + updateNowPlayingInfo() + }) + + log.Info("macOS System Media Control initialized") } func Destroy() { - // stub + C.clearNowPlaying() + + // Unsubscribe from all events + global.EventBus.Unsubscribe(events.PlayerPlayingUpdate, "sysmediacontrol.update_playing") + global.EventBus.Unsubscribe(events.PlayerPropertyPauseUpdate, "sysmediacontrol.update_paused") + global.EventBus.Unsubscribe(events.PlayerPropertyDurationUpdate, "sysmediacontrol.properties.duration") + global.EventBus.Unsubscribe(events.PlayerPropertyTimePosUpdate, "sysmediacontrol.properties.time_pos") + + log.Info("macOS System Media Control destroyed") }