diff --git a/backend/app.go b/backend/app.go index 71b383a..dca6bc9 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 @@ -381,7 +382,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 @@ -389,17 +390,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() } }) @@ -428,18 +429,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 { @@ -551,6 +571,10 @@ func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error { return cli.SeekBackOrPrevious() case *FlagNext: return cli.SeekNext() + case *FlagStop: + return cli.Stop() + case *FlagStopAfterCurrent: + return cli.StopAfterCurrent() case *FlagShow: return cli.Show() case VolumeCLIArg >= 0: diff --git a/backend/cmdlineoptions.go b/backend/cmdlineoptions.go index 531eed3..7375e00 100644 --- a/backend/cmdlineoptions.go +++ b/backend/cmdlineoptions.go @@ -22,16 +22,18 @@ var ( SearchPlaylistCLIArg string = "" SearchTrackCLIArg string = "" - FlagPlay = flag.Bool("play", false, "unpause or begin playback") - FlagPause = flag.Bool("pause", false, "pause playback") - FlagPlayPause = flag.Bool("play-pause", false, "toggle play/pause state") - FlagPrevious = flag.Bool("previous", false, "seek to previous track or beginning of current") - FlagNext = flag.Bool("next", false, "seek to next track") - FlagStartMinimized = flag.Bool("start-minimized", false, "start app minimized") - FlagShow = flag.Bool("show", false, "show minimized app") - FlagShuffle = flag.Bool("shuffle", false, "shuffle the tracklist (to be used with either -play-album or -play-playlist)") - FlagVersion = flag.Bool("version", false, "print app version and exit") - FlagHelp = flag.Bool("help", false, "print command line options and exit") + FlagPlay = flag.Bool("play", false, "unpause or begin playback") + FlagPause = flag.Bool("pause", false, "pause playback") + FlagPlayPause = flag.Bool("play-pause", false, "toggle play/pause state") + FlagPrevious = flag.Bool("previous", false, "seek to previous track or beginning of current") + FlagNext = flag.Bool("next", false, "seek to next track") + FlagStop = flag.Bool("stop", false, "stop playback") + FlagStopAfterCurrent = flag.Bool("stop-after-current", false, "stop playback after current track") + FlagStartMinimized = flag.Bool("start-minimized", false, "start app minimized") + FlagShow = flag.Bool("show", false, "show minimized app") + FlagShuffle = flag.Bool("shuffle", false, "shuffle the tracklist (to be used with either -play-album or -play-playlist)") + FlagVersion = flag.Bool("version", false, "print app version and exit") + FlagHelp = flag.Bool("help", false, "print command line options and exit") FlagPlayAlbum *bool FlagPlayPlaylist *bool diff --git a/backend/ipc/api.go b/backend/ipc/api.go index edb6430..db4d636 100644 --- a/backend/ipc/api.go +++ b/backend/ipc/api.go @@ -7,25 +7,26 @@ import ( ) const ( - PingPath = "/ping" - PlayPath = "/transport/play" - PlayAlbumPath = "/transport/play-album" // ?id=&t=&s= - PlayPlaylistPath = "/transport/play-playlist" // ?id=&t=&s= - PlayTrackPath = "/transport/play-track" // ?id= - SearchAlbumPath = "/transport/search-album" // ?s= - SearchPlaylistPath = "/transport/search-playlist" // ?s= - SearchTrackPath = "/transport/search-track" // ?s= - PlayPausePath = "/transport/playpause" - PausePath = "/transport/pause" - StopPath = "/transport/stop" - PreviousPath = "/transport/previous" - NextPath = "/transport/next" - TimePosPath = "/transport/timepos" // ?s= - SeekByPath = "/transport/seek-by" // ?s=<+/- seconds> - VolumePath = "/volume" // ?v= - VolumeAdjustPath = "/volume/adjust" // ?pct=<+/- percentage> - ShowPath = "/window/show" - QuitPath = "/window/quit" + PingPath = "/ping" + PlayPath = "/transport/play" + PlayAlbumPath = "/transport/play-album" // ?id=&t=&s= + PlayPlaylistPath = "/transport/play-playlist" // ?id=&t=&s= + PlayTrackPath = "/transport/play-track" // ?id= + SearchAlbumPath = "/transport/search-album" // ?s= + SearchPlaylistPath = "/transport/search-playlist" // ?s= + SearchTrackPath = "/transport/search-track" // ?s= + PlayPausePath = "/transport/playpause" + PausePath = "/transport/pause" + StopPath = "/transport/stop" + StopAfterCurrentPath = "/transport/stop-after-current" + PreviousPath = "/transport/previous" + NextPath = "/transport/next" + TimePosPath = "/transport/timepos" // ?s= + SeekByPath = "/transport/seek-by" // ?s=<+/- seconds> + VolumePath = "/volume" // ?v= + VolumeAdjustPath = "/volume/adjust" // ?pct=<+/- percentage> + ShowPath = "/window/show" + QuitPath = "/window/quit" ) type Response struct { diff --git a/backend/ipc/client.go b/backend/ipc/client.go index 95142cc..88c703d 100644 --- a/backend/ipc/client.go +++ b/backend/ipc/client.go @@ -76,6 +76,16 @@ func (c *Client) PlayPause() error { return err } +func (c *Client) Stop() error { + _, err := c.sendRequest(StopPath) + return err +} + +func (c *Client) StopAfterCurrent() error { + _, err := c.sendRequest(StopAfterCurrentPath) + return err +} + func (c *Client) SeekNext() error { _, err := c.sendRequest(NextPath) return err diff --git a/backend/ipc/server.go b/backend/ipc/server.go index 53c24dd..f9d28c5 100644 --- a/backend/ipc/server.go +++ b/backend/ipc/server.go @@ -18,6 +18,7 @@ type PlaybackHandler interface { Continue() SeekBackOrPrevious() SeekNext() + SetStopAfterCurrent(bool) SeekSeconds(float64) SeekBySeconds(float64) Volume() int @@ -75,6 +76,9 @@ func (s *serverImpl) createHandler() http.Handler { m.HandleFunc(PausePath, s.makeSimpleEndpointHandler(s.pbHandler.Pause)) m.HandleFunc(PlayPausePath, s.makeSimpleEndpointHandler(s.pbHandler.PlayPause)) m.HandleFunc(StopPath, s.makeSimpleEndpointHandler(s.pbHandler.Stop)) + m.HandleFunc(StopAfterCurrentPath, s.makeSimpleEndpointHandler(func() { + s.pbHandler.SetStopAfterCurrent(true) + })) m.HandleFunc(PreviousPath, s.makeSimpleEndpointHandler(s.pbHandler.SeekBackOrPrevious)) m.HandleFunc(NextPath, s.makeSimpleEndpointHandler(s.pbHandler.SeekNext)) m.HandleFunc(TimePosPath, s.makeFloatEndpointHandler("s", s.pbHandler.SeekSeconds)) diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index 48c091f..bf49c60 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -449,6 +449,16 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track { t.FilePath = ch.MediaSources[0].Path t.Size = int64(ch.MediaSources[0].Size) t.BitRate = ch.MediaSources[0].Bitrate / 1000 + if strs := ch.MediaSources[0].MediaStreams; len(strs) > 0 { + t.SampleRate = strs[0].SampleRate + t.BitDepth = strs[0].BitDepth + t.Channels = strs[0].Channels + } + } + if len(ch.MediaStreams) > 0 { + t.SampleRate = max(t.SampleRate, ch.MediaStreams[0].SampleRate) + t.BitDepth = max(t.BitDepth, ch.MediaStreams[0].BitDepth) + t.Channels = max(t.Channels, ch.MediaStreams[0].Channels) } return t } diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 626ea62..809e9ec 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -149,6 +149,9 @@ type Track struct { Comment string BPM int ReplayGain ReplayGainInfo + SampleRate int + BitDepth int + Channels int } type ReplayGainInfo struct { diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index c3bf658..478ac5a 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -573,6 +573,9 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { Comment: ch.Comment, BPM: ch.BPM, ReplayGain: rGain, + SampleRate: ch.SamplingRate, + BitDepth: ch.BitDepth, + Channels: ch.ChannelCount, } } diff --git a/backend/playbackengine.go b/backend/playbackengine.go index fa0d15f..ae0dbdd 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -65,6 +65,8 @@ type playbackEngine struct { isRadio bool loopMode LoopMode + stopAfterCurrent bool // flag to stop playback after current track ends + // flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks wasStopped bool // true iff player was stopped before handleOnTrackChange invocation alreadyScrobbled bool // true iff the previously-playing track was already scrobbled @@ -334,6 +336,15 @@ func (p *playbackEngine) Stop() error { return p.player.Stop(false) } +func (p *playbackEngine) SetStopAfterCurrent(stopAfterCurrent bool) { + p.stopAfterCurrent = stopAfterCurrent + if p.stopAfterCurrent { + p.setNextTrack(-1) // clear next playing track from internal player, if any + } else if p.loopMode != LoopNone || p.nowPlayingIdx < len(p.playQueue)-1 { + p.needToSetNextTrack = true // need to restore next track to internal player queue + } +} + func (p *playbackEngine) Pause() error { return p.player.Pause() } @@ -634,6 +645,7 @@ func (p *playbackEngine) handleOnStopped() { p.alreadyScrobbled = false p.wasStopped = true p.nowPlayingIdx = -1 + p.stopAfterCurrent = false } // to be invoked as soon as the next item in the queue that should play changes @@ -855,7 +867,7 @@ func (p *playbackEngine) handleTimePosUpdate(seeked bool) { meta = np.Metadata() } isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > meta.Duration.Seconds()-10 - if p.needToSetNextTrack && isNearEnd { + if p.needToSetNextTrack && !p.stopAfterCurrent && isNearEnd { p.needToSetNextTrack = false p.setNextTrack(p.nextPlayingIndex()) } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 72109f7..14dbede 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -669,6 +669,14 @@ func (p *PlaybackManager) PlayPause() { } } +func (p *PlaybackManager) SetStopAfterCurrent(stopAfterCurrent bool) { + p.engine.SetStopAfterCurrent(stopAfterCurrent) +} + +func (p *PlaybackManager) IsStopAfterCurrent() bool { + return p.engine.stopAfterCurrent +} + func (p *PlaybackManager) enqueueAutoplayTracks() { nowPlaying := p.NowPlaying() if nowPlaying == nil { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index bdaf4a8..399f400 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -229,6 +229,9 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat Title: meta.Name, Seekable: true, } + } else { + // empty media item to signify erasing next track in device queue + media = &avtransport.MediaItem{} } ctx, cancel := context.WithCancel(context.Background()) 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/go.mod b/go.mod index c956a07..29a0db3 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 github.com/dweymouth/fyne-advanced-list v0.0.0-20250211191927-58ea85eec72c github.com/dweymouth/fyne-tooltip v0.3.0 - github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f + github.com/dweymouth/go-jellyfin v0.0.0-20250808023725-196437af15a6 github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 github.com/godbus/dbus/v5 v5.1.0 diff --git a/go.sum b/go.sum index 142f6fa..21bade3 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4 h1:Q3r94Ac github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f h1:QsKPwFpTHuYHEEuhvp4VBClkHh00bNNgQ/2Ij1bkk8M= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= +github.com/dweymouth/go-jellyfin v0.0.0-20250808023725-196437af15a6 h1:IFewD1zMs7eWkqmzdGeQP9lavYboJHwGqm1oOa7wZC8= +github.com/dweymouth/go-jellyfin v0.0.0-20250808023725-196437af15a6/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0 h1:mYcctuWgVArHhSLJxndlUM43C3hoE18BLDBkXKM2tl0= github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0/go.mod h1:bp2870jtp/ixAJLIOdShBfl1WpyLGDZ57jnVWMgkgIc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= diff --git a/main.go b/main.go index b9ea66f..d431273 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" "golang.org/x/term" @@ -85,6 +89,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) @@ -101,13 +119,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) }) } }) @@ -126,3 +144,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/translations/en.json b/res/translations/en.json index 1170cff..a5bc43c 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -38,12 +38,14 @@ "Autoplay": "Autoplay", "Autoselect device": "Autoselect device", "Back": "Back", + "Bit depth": "Bit depth", "Bit rate": "Bit rate", "Bold font": "Bold font", "BPM": "BPM", "Broadcast": "Broadcast", "Cancel": "Cancel", "Cast to device": "Cast to device", + "Channels": "Channels", "Check for Updates": "Check for Updates", "Close": "Close", "Close to system tray": "Close to system tray", @@ -175,6 +177,7 @@ "ReplayGain mode": "ReplayGain mode", "ReplayGain preamp": "ReplayGain preamp", "Restart required": "Restart required", + "Sample rate": "Sample rate", "Save play queue on exit": "Save play queue on exit", "Saved at": "Saved at", "Scrobble when": "Scrobble when", @@ -213,6 +216,7 @@ "Soundtrack": "Soundtrack", "Spoken Word": "Spoken Word", "Startup page": "Startup page", + "Stop after current track": "Stop after current track", "Stopped": "Stopped", "Success": "Success", "Support the project": "Support the project", 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 diff --git a/ui/controller/controller.go b/ui/controller/controller.go index b6a6677..927cd6f 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -27,6 +27,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -57,6 +58,7 @@ type Controller struct { popUpQueue *widget.PopUp popUpQueueList *widgets.PlayQueueList + stopAfterCurrent *widget.Check popUpQueueLastUsed int64 escapablePopUp fyne.CanvasObject haveModal bool @@ -195,7 +197,11 @@ func (m *Controller) ShowPopUpPlayQueue() { title := widget.NewRichTextWithText(lang.L("Play Queue")) title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter title.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = true - ctr := container.NewBorder(title, nil, nil, nil, + m.stopAfterCurrent = widget.NewCheck(lang.L("Stop after current track"), func(b bool) { + m.App.PlaybackManager.SetStopAfterCurrent(b) + }) + bottomRow := container.NewHBox(layout.NewSpacer(), m.stopAfterCurrent) + ctr := container.NewBorder(title, bottomRow, nil, nil, container.NewPadded(m.popUpQueueList), ) m.popUpQueue = widget.NewPopUp(ctr, m.MainWindow.Canvas()) @@ -224,6 +230,7 @@ func (m *Controller) ShowPopUpPlayQueue() { fynetooltip.DestroyPopUpToolTipLayer(m.popUpQueue) m.popUpQueue = nil m.popUpQueueList = nil + m.stopAfterCurrent = nil m.popUpQueueLastUsed = 0 t.Stop() return @@ -252,6 +259,7 @@ func (m *Controller) ShowPopUpPlayQueue() { )) pop.Resize(size) popUpQueueList.ScrollToNowPlaying() // must come after resize + m.stopAfterCurrent.SetChecked(m.App.PlaybackManager.IsStopAfterCurrent()) pop.ShowAtPosition(fyne.NewPos( canvasSize.Width-size.Width-10, canvasSize.Height-size.Height-100, diff --git a/ui/dialogs/trackinfodialog.go b/ui/dialogs/trackinfodialog.go index f4d7205..3002522 100644 --- a/ui/dialogs/trackinfodialog.go +++ b/ui/dialogs/trackinfodialog.go @@ -105,6 +105,15 @@ func (t *TrackInfoDialog) CreateRenderer() fyne.WidgetRenderer { addFormRow(c, lang.L("Content type"), t.track.ContentType) addFormRow(c, lang.L("Bit rate"), fmt.Sprintf("%d kbps", t.track.BitRate)) + if t.track.SampleRate > 0 { + addFormRow(c, lang.L("Sample rate"), fmt.Sprintf("%d Hz", t.track.SampleRate)) + } + if t.track.BitDepth > 0 { + addFormRow(c, lang.L("Bit depth"), strconv.Itoa(t.track.BitDepth)) + } + if t.track.Channels > 0 { + addFormRow(c, lang.L("Channels"), strconv.Itoa(t.track.Channels)) + } addFormRow(c, lang.L("File size"), util.BytesToSizeString(t.track.Size)) addFormRow(c, lang.L("Play count"), strconv.Itoa(t.track.PlayCount))