diff --git a/backend/app.go b/backend/app.go index 6597581..4b4823d 100644 --- a/backend/app.go +++ b/backend/app.go @@ -20,6 +20,7 @@ import ( "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" + "github.com/dweymouth/supersonic/backend/windows" "github.com/google/uuid" "github.com/20after4/configdir" @@ -49,7 +50,7 @@ type App struct { LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler - WinSMTC *SMTC + WinSMTC *windows.SMTC ipcServer ipc.IPCServer LrcLibFetcher *LrcLibFetcher @@ -378,7 +379,7 @@ func (a *App) setupMPRIS(mprisAppName string) { } func (a *App) SetupWindowsSMTC(hwnd uintptr) { - smtc, err := InitSMTCForWindow(hwnd) + smtc, err := windows.InitSMTCForWindow(hwnd) if err != nil { log.Printf("error initializing SMTC: %d", err) return @@ -386,17 +387,17 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { a.WinSMTC = smtc smtc.UpdateMetadata(a.displayAppName, "") - smtc.OnButtonPressed(func(btn SMTCButton) { + smtc.OnButtonPressed(func(btn windows.SMTCButton) { switch btn { - case SMTCButtonPlay: + case windows.SMTCButtonPlay: a.PlaybackManager.Continue() - case SMTCButtonPause: + case windows.SMTCButtonPause: a.PlaybackManager.Pause() - case SMTCButtonNext: + case windows.SMTCButtonNext: a.PlaybackManager.SeekNext() - case SMTCButtonPrevious: + case windows.SMTCButtonPrevious: a.PlaybackManager.SeekBackOrPrevious() - case SMTCButtonStop: + case windows.SMTCButtonStop: a.PlaybackManager.Stop() } }) @@ -425,18 +426,37 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) - smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying) + smtc.UpdatePlaybackState(windows.SMTCPlaybackStatePlaying) }) a.PlaybackManager.OnPaused(func() { smtc.SetEnabled(true) - smtc.UpdatePlaybackState(SMTCPlaybackStatePaused) + smtc.UpdatePlaybackState(windows.SMTCPlaybackStatePaused) }) a.PlaybackManager.OnStopped(func() { smtc.SetEnabled(false) - smtc.UpdatePlaybackState(SMTCPlaybackStateStopped) + smtc.UpdatePlaybackState(windows.SMTCPlaybackStateStopped) }) } +func (a *App) SetupWindowsTaskbarButtons(hwnd uintptr) { + windows.InitializeTaskbarButtons(hwnd, func(btn windows.TaskbarButton) { + switch btn { + case windows.TaskbarButtonPrevious: + a.PlaybackManager.SeekBackOrPrevious() + case windows.TaskbarButtonPlayPause: + a.PlaybackManager.PlayPause() + case windows.TaskbarButtonNext: + a.PlaybackManager.SeekNext() + } + }) + a.PlaybackManager.OnPlaying(func() { + windows.SetTaskbarButtonIsPlaying(true) + }) + f := func() { windows.SetTaskbarButtonIsPlaying(false) } + a.PlaybackManager.OnPaused(f) + a.PlaybackManager.OnStopped(f) +} + func (a *App) LoginToDefaultServer() error { serverCfg := a.ServerManager.GetDefaultServer() if serverCfg == nil { diff --git a/backend/smtc.go b/backend/windows/smtc.go similarity index 99% rename from backend/smtc.go rename to backend/windows/smtc.go index 0c00373..182af90 100644 --- a/backend/smtc.go +++ b/backend/windows/smtc.go @@ -1,6 +1,6 @@ //go:build windows -package backend +package windows /* #cgo CFLAGS: -I . diff --git a/backend/smtc_cfuncs.go b/backend/windows/smtc_cfuncs.go similarity index 92% rename from backend/smtc_cfuncs.go rename to backend/windows/smtc_cfuncs.go index 9ccd949..cb3489e 100644 --- a/backend/smtc_cfuncs.go +++ b/backend/windows/smtc_cfuncs.go @@ -1,6 +1,6 @@ //go:build windows -package backend +package windows /* void btn_callback_cgo(int in) { diff --git a/backend/smtc_unsupported.go b/backend/windows/smtc_unsupported.go similarity index 98% rename from backend/smtc_unsupported.go rename to backend/windows/smtc_unsupported.go index 18cf09e..e00f7d3 100644 --- a/backend/smtc_unsupported.go +++ b/backend/windows/smtc_unsupported.go @@ -1,6 +1,6 @@ //go:build !windows -package backend +package windows import "errors" diff --git a/backend/windows/taskbar_buttons.c b/backend/windows/taskbar_buttons.c new file mode 100644 index 0000000..0bb28dd --- /dev/null +++ b/backend/windows/taskbar_buttons.c @@ -0,0 +1,175 @@ +//go:build windows + +#include +#include +#include +#include +#include "taskbar_buttons.h" + +DEFINE_GUID(IID_ITaskbarList3, + 0xEA1AFB91, 0x9E28, 0x4B86, 0x90, 0xE9, 0x9E, 0x9F, 0x8A, 0x5E, 0xEF, 0xAF); + +#define WM_SET_PLAYING_STATE (WM_APP + 1) + +static ITaskbarList3 *g_taskbar = NULL; +static ThumbnailCallback g_callback = NULL; +static HWND g_mainHWnd = NULL; +static WNDPROC g_originalProc = NULL; + +THUMBBUTTON g_thumbButtons[3]; + +static HICON g_prevIcon; +static HICON g_nextIcon; +static HICON g_playIcon; +static HICON g_pauseIcon; + +static wchar_t g_tooltipPlay[64] = {0}; +static wchar_t g_tooltipPause[64] = {0}; +static wchar_t g_tooltipPrev[64] = {0}; +static wchar_t g_tooltipNext[64] = {0}; + +static void utf8_to_utf16(const char* utf8, wchar_t* utf16buf, size_t maxChars) { + MultiByteToWideChar(CP_UTF8, 0, utf8, -1, utf16buf, (int)maxChars); +} + +void set_tooltips_utf8(const char* prev, const char* next, const char* play, const char* pause) { + utf8_to_utf16(play, g_tooltipPlay, _countof(g_tooltipPlay)); + utf8_to_utf16(pause, g_tooltipPause, _countof(g_tooltipPause)); + utf8_to_utf16(prev, g_tooltipPrev, _countof(g_tooltipPrev)); + utf8_to_utf16(next, g_tooltipNext, _countof(g_tooltipNext)); +} + +static HICON create_icon_from_bgra(const void *bgra, int width, int height) { + HICON hIcon = NULL; + + // Create mask bitmap (not used, but required) + HBITMAP hMonoMask = CreateBitmap(width, height, 1, 1, NULL); + + // Create color bitmap from provided pixels + BITMAPV5HEADER bi; + ZeroMemory(&bi, sizeof(bi)); + bi.bV5Size = sizeof(bi); + bi.bV5Width = width; + bi.bV5Height = -height; // top-down DIB + bi.bV5Planes = 1; + bi.bV5BitCount = 32; + bi.bV5Compression = BI_BITFIELDS; + bi.bV5RedMask = 0x00FF0000; + bi.bV5GreenMask = 0x0000FF00; + bi.bV5BlueMask = 0x000000FF; + bi.bV5AlphaMask = 0xFF000000; + + void *bits = NULL; + HDC hdc = GetDC(NULL); + HBITMAP hBmp = CreateDIBSection(hdc, (BITMAPINFO*)&bi, DIB_RGB_COLORS, &bits, NULL, 0); + ReleaseDC(NULL, hdc); + + if (hBmp && bits) { + memcpy(bits, bgra, width * height * 4); + + ICONINFO ii; + ZeroMemory(&ii, sizeof(ii)); + ii.fIcon = TRUE; + ii.hbmMask = hMonoMask; + ii.hbmColor = hBmp; + + hIcon = CreateIconIndirect(&ii); + + DeleteObject(hBmp); + DeleteObject(hMonoMask); + } + return hIcon; +} + +int initialize_taskbar_icons(const void *bgraPrev, const void *bgraNext, const void *bgraPlay, const void *bgraPause, int width, int height) { + g_prevIcon = create_icon_from_bgra(bgraPrev, width, height); + g_nextIcon = create_icon_from_bgra(bgraNext, width, height); + g_playIcon = create_icon_from_bgra(bgraPlay, width, height); + g_pauseIcon = create_icon_from_bgra(bgraPause, width, height); + + return 0; +} + +LRESULT CALLBACK OverrideWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == WM_COMMAND) { + int buttonId = LOWORD(wParam); + if (g_callback) { + g_callback(buttonId); + return 0; + } + } + else if (msg == WM_SET_PLAYING_STATE) { + if (g_taskbar && g_mainHWnd && g_playIcon && g_pauseIcon) { + int playing = LOWORD(wParam) ? 1 : 0; + g_thumbButtons[1].hIcon = playing ? g_pauseIcon : g_playIcon; + wcscpy_s(g_thumbButtons[1].szTip, ARRAYSIZE(g_thumbButtons[1].szTip), playing ? g_tooltipPause : g_tooltipPlay); + g_taskbar->lpVtbl->ThumbBarUpdateButtons( + g_taskbar, + g_mainHWnd, + ARRAYSIZE(g_thumbButtons), + g_thumbButtons + ); + } + return 0; + } + + return CallWindowProc(g_originalProc, hwnd, msg, wParam, lParam); +} + +int set_is_playing(int playing) { + if (g_mainHWnd) { + PostMessage(g_mainHWnd, WM_SET_PLAYING_STATE, (WPARAM)playing, 0); + return 0; + } + return -1; +} + +int initialize_taskbar_buttons(void *hwndPtr, ThumbnailCallback cb) { + g_callback = cb; + g_mainHWnd = (HWND)hwndPtr; + + // subclass the WndProc with our OverrideWndProc + g_originalProc = (WNDPROC)SetWindowLongPtr(g_mainHWnd, GWLP_WNDPROC, (LONG_PTR)OverrideWndProc); + + CoInitialize(NULL); + CoCreateInstance(&CLSID_TaskbarList, NULL, CLSCTX_INPROC_SERVER, &IID_ITaskbarList3, (void**)&g_taskbar); + + if (g_taskbar) { + g_taskbar->lpVtbl->HrInit(g_taskbar); + } + + if (!g_taskbar || !g_mainHWnd) return -1; + + ZeroMemory(g_thumbButtons, sizeof(g_thumbButtons)); + + g_thumbButtons[0].dwMask = THB_FLAGS | THB_TOOLTIP; + g_thumbButtons[0].iId = 1; + g_thumbButtons[0].dwFlags = THBF_ENABLED; + wcscpy_s(g_thumbButtons[0].szTip, ARRAYSIZE(g_thumbButtons[0].szTip), g_tooltipPrev); + if (g_prevIcon) { + g_thumbButtons[0].dwMask |= THB_ICON; + g_thumbButtons[0].hIcon = g_prevIcon; + } + + g_thumbButtons[1].dwMask = THB_FLAGS | THB_TOOLTIP; + g_thumbButtons[1].iId = 2; + g_thumbButtons[1].dwFlags = THBF_ENABLED; + wcscpy_s(g_thumbButtons[1].szTip, ARRAYSIZE(g_thumbButtons[1].szTip), g_tooltipPlay); + if (g_playIcon) { + g_thumbButtons[1].dwMask |= THB_ICON; + g_thumbButtons[1].hIcon = g_playIcon; + } + + g_thumbButtons[2].dwMask = THB_FLAGS | THB_TOOLTIP; + g_thumbButtons[2].iId = 3; + g_thumbButtons[2].dwFlags = THBF_ENABLED; + wcscpy_s(g_thumbButtons[2].szTip, ARRAYSIZE(g_thumbButtons[2].szTip), g_tooltipNext); + if (g_nextIcon) { + g_thumbButtons[2].dwMask |= THB_ICON; + g_thumbButtons[2].hIcon = g_nextIcon; + } + + g_taskbar->lpVtbl->ThumbBarAddButtons(g_taskbar, g_mainHWnd, ARRAYSIZE(g_thumbButtons), g_thumbButtons); + + return 0; +} \ No newline at end of file diff --git a/backend/windows/taskbar_buttons.go b/backend/windows/taskbar_buttons.go new file mode 100644 index 0000000..782008e --- /dev/null +++ b/backend/windows/taskbar_buttons.go @@ -0,0 +1,148 @@ +//go:build windows + +package windows + +/* +#cgo LDFLAGS: -lole32 +#include +#include "taskbar_buttons.h" + +extern void goButtonClicked(int); +*/ +import "C" +import ( + "errors" + "image" + "unsafe" +) + +var gTaskbarButtonCallback func(TaskbarButton) + +// InitializeTaskbarIcons supplies the image icons that will be used for the taskbar buttons. +// They must all have the same pixel dimensions. +// This should be called before InitializeTaskbarButtons or the buttons will not have icons. +func InitializeTaskbarIcons(prev, next, play, pause image.Image) error { + pB := imageToBGRA(prev) + nB := imageToBGRA(next) + plB := imageToBGRA(play) + paB := imageToBGRA(pause) + bnds := prev.Bounds() + + C.initialize_taskbar_icons(unsafe.Pointer(&pB[0]), unsafe.Pointer(&nB[0]), unsafe.Pointer(&plB[0]), unsafe.Pointer(&paB[0]), C.int(bnds.Dx()), C.int(bnds.Dy())) + return nil +} + +// SetTaskbarButtonToolTips should be called before InitializeTaskbarButtons to +// set the tool tips that will be used for the buttons +func SetTaskbarButtonToolTips(prev, next, play, pause string) error { + cPlay := C.CString(play) + defer C.free(unsafe.Pointer(cPlay)) + cPause := C.CString(pause) + defer C.free(unsafe.Pointer(cPause)) + cPrev := C.CString(prev) + defer C.free(unsafe.Pointer(cPrev)) + cNext := C.CString(next) + defer C.free(unsafe.Pointer(cNext)) + C.set_tooltips_utf8(cPrev, cNext, cPlay, cPause) + + return nil +} + +func InitializeTaskbarButtons(hwnd uintptr, callback func(TaskbarButton)) error { + gTaskbarButtonCallback = callback + C.initialize_taskbar_buttons(unsafe.Pointer(hwnd), C.ThumbnailCallback(C.goButtonClicked)) + return nil +} + +func SetTaskbarButtonIsPlaying(isPlaying bool) error { + i := C.int(0) + if isPlaying { + i = C.int(1) + } + if ret := int(C.set_is_playing(i)); ret == 0 { + return nil + } + return errors.New("failed to set taskbar button is playing state") +} + +//export goButtonClicked +func goButtonClicked(buttonID C.int) { + if gTaskbarButtonCallback != nil { + gTaskbarButtonCallback(TaskbarButton(buttonID)) + } +} + +// imageToBGRA converts an image.Image to a BGRA byte slice. +// For *image.RGBA, it un-premultiplies the colors to straight alpha. +func imageToBGRA(img image.Image) []byte { + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + bgra := make([]byte, w*h*4) + + i := 0 + switch src := img.(type) { + case *image.NRGBA: + // Straight alpha, just swap channels + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + off := src.PixOffset(bounds.Min.X, y) + for x := 0; x < w; x++ { + r := src.Pix[off+0] + g := src.Pix[off+1] + b := src.Pix[off+2] + a := src.Pix[off+3] + bgra[i+0] = b + bgra[i+1] = g + bgra[i+2] = r + bgra[i+3] = a + off += 4 + i += 4 + } + } + + case *image.RGBA: + // Premultiplied alpha, un-premultiply to straight alpha + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + off := src.PixOffset(bounds.Min.X, y) + for x := 0; x < w; x++ { + r := src.Pix[off+0] + g := src.Pix[off+1] + b := src.Pix[off+2] + a := src.Pix[off+3] + + if a != 0 { + // Convert from premultiplied to straight alpha + r = uint8((uint16(r) * 0xFF) / uint16(a)) + g = uint8((uint16(g) * 0xFF) / uint16(a)) + b = uint8((uint16(b) * 0xFF) / uint16(a)) + } + + bgra[i+0] = b + bgra[i+1] = g + bgra[i+2] = r + bgra[i+3] = a + off += 4 + i += 4 + } + } + + default: + // Fallback: handle any other image.Image type via At() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r16, g16, b16, a16 := img.At(x, y).RGBA() + // Convert from 16-bit [0, 65535] to 8-bit [0, 255] + r := uint8(r16 >> 8) + g := uint8(g16 >> 8) + b := uint8(b16 >> 8) + a := uint8(a16 >> 8) + bgra[i+0] = b + bgra[i+1] = g + bgra[i+2] = r + bgra[i+3] = a + i += 4 + } + } + } + + return bgra +} diff --git a/backend/windows/taskbar_buttons.h b/backend/windows/taskbar_buttons.h new file mode 100644 index 0000000..f487117 --- /dev/null +++ b/backend/windows/taskbar_buttons.h @@ -0,0 +1,26 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*ThumbnailCallback)(int buttonId); + +// sets the tool tip strings for prev, next, play, and pause +// should be called before initialize_taskbar_buttons or they will not have tool tips +void set_tooltips_utf8(const char* prev, const char* next, const char* play, const char* pause); + +// sets the icons that will be used for the buttons. +// the arguments are pointers to BGRA pixel data, and the dimensions (w, h) of all 4 images. +int initialize_taskbar_icons(const void *bgraPrev, const void *bgraNext, const void *bgraPlay, const void *bgraPause, int width, int height); + +// adds the buttons to the window and registers the given callback to receive the button press events. +int initialize_taskbar_buttons(void *hwndPtr, ThumbnailCallback cb); + +// sets whether the player is playing. controls which icon/tooltip the center button uses. +// safe to call from any thread +int set_is_playing(int playing); + +#ifdef __cplusplus +} +#endif diff --git a/backend/windows/taskbar_buttons_consts.go b/backend/windows/taskbar_buttons_consts.go new file mode 100644 index 0000000..0a9325a --- /dev/null +++ b/backend/windows/taskbar_buttons_consts.go @@ -0,0 +1,9 @@ +package windows + +type TaskbarButton int + +const ( + TaskbarButtonPrevious TaskbarButton = 1 + TaskbarButtonPlayPause TaskbarButton = 2 + TaskbarButtonNext TaskbarButton = 3 +) diff --git a/backend/windows/taskbar_buttons_unsupported.go b/backend/windows/taskbar_buttons_unsupported.go new file mode 100644 index 0000000..a83122a --- /dev/null +++ b/backend/windows/taskbar_buttons_unsupported.go @@ -0,0 +1,26 @@ +//go:build !windows + +package windows + +import ( + "errors" + "image" +) + +var err = errors.New("taskbar buttons unsupported") + +func InitializeTaskbarButtons(hwnd uintptr, callback func(TaskbarButton)) error { + return err +} + +func SetTaskbarButtonToolTips(prev, next, play, pause string) error { + return err +} + +func InitializeTaskbarIcons(prev, next, play, pause image.Image) error { + return err +} + +func SetTaskbarButtonIsPlaying(isPlaying bool) error { + return err +} diff --git a/main.go b/main.go index 25e8563..2922325 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "flag" "fmt" + "image/png" "log" "os" "runtime" @@ -10,7 +12,9 @@ import ( "sync" "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/windows" "github.com/dweymouth/supersonic/res" + "github.com/dweymouth/supersonic/res/wintaskbarthumbs" "github.com/dweymouth/supersonic/ui" "github.com/dweymouth/supersonic/ui/util" @@ -75,6 +79,20 @@ func main() { } } + if runtime.GOOS == "windows" { + if err := initWindowsTaskbarIcons(); err != nil { + log.Printf("Error initializing taskbar thumbnail icons: %s", err.Error()) + } + if err := windows.SetTaskbarButtonToolTips( + lang.L("Previous"), + lang.L("Next"), + lang.L("Play"), + lang.L("Pause"), + ); err != nil { + log.Printf("error initializing taskbar button tool tips: %s", err.Error()) + } + } + fyneApp := app.New() fyneApp.SetIcon(res.ResAppicon256Png) @@ -91,13 +109,13 @@ func main() { mainWindow.Controller.DoConnectToServerWorkflow(defaultServer) } - if myApp.Config.Application.EnableOSMediaPlayerAPIs { + if runtime.GOOS == "windows" { mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { - // intialize Windows SMTC - if runtime.GOOS == "windows" { - hwnd := ctx.(driver.WindowsWindowContext).HWND + hwnd := ctx.(driver.WindowsWindowContext).HWND + if myApp.Config.Application.EnableOSMediaPlayerAPIs { myApp.SetupWindowsSMTC(hwnd) } + myApp.SetupWindowsTaskbarButtons(hwnd) }) } }) @@ -116,3 +134,26 @@ func main() { log.Println("Running shutdown tasks...") myApp.Shutdown() } + +func initWindowsTaskbarIcons() error { + play, err := png.Decode(bytes.NewReader(wintaskbarthumbs.MediaPlayPNG)) + if err != nil { + return err + } + pause, err := png.Decode(bytes.NewReader(wintaskbarthumbs.MediaPausePNG)) + if err != nil { + return err + } + prev, err := png.Decode(bytes.NewReader(wintaskbarthumbs.MediaSeekPreviousPNG)) + if err != nil { + return err + } + next, err := png.Decode(bytes.NewReader(wintaskbarthumbs.MediaSeekNextPNG)) + if err != nil { + return err + } + + windows.InitializeTaskbarIcons(prev, next, play, pause) + + return nil +} diff --git a/res/wintaskbarthumbs/embed.go b/res/wintaskbarthumbs/embed.go new file mode 100644 index 0000000..fa48d2c --- /dev/null +++ b/res/wintaskbarthumbs/embed.go @@ -0,0 +1,15 @@ +package wintaskbarthumbs + +import _ "embed" + +//go:embed media_pause.png +var MediaPausePNG []byte + +//go:embed media_play.png +var MediaPlayPNG []byte + +//go:embed media_seek_next.png +var MediaSeekNextPNG []byte + +//go:embed media_seek_previous.png +var MediaSeekPreviousPNG []byte diff --git a/res/wintaskbarthumbs/media_pause.png b/res/wintaskbarthumbs/media_pause.png new file mode 100644 index 0000000..ed35378 Binary files /dev/null and b/res/wintaskbarthumbs/media_pause.png differ diff --git a/res/wintaskbarthumbs/media_play.png b/res/wintaskbarthumbs/media_play.png new file mode 100644 index 0000000..6a196c7 Binary files /dev/null and b/res/wintaskbarthumbs/media_play.png differ diff --git a/res/wintaskbarthumbs/media_seek_next.png b/res/wintaskbarthumbs/media_seek_next.png new file mode 100644 index 0000000..c630bfd Binary files /dev/null and b/res/wintaskbarthumbs/media_seek_next.png differ diff --git a/res/wintaskbarthumbs/media_seek_previous.png b/res/wintaskbarthumbs/media_seek_previous.png new file mode 100644 index 0000000..3295b63 Binary files /dev/null and b/res/wintaskbarthumbs/media_seek_previous.png differ