From ee26e5b5c9e0e59dc501873cb2ac4f641a385249 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:19:37 -0400 Subject: [PATCH] fix coverart spotify hook on windows --- native/src/windows_capture.cpp | 102 ++++++++++++++++++++++++ src/main/services/macSpotifyProvider.ts | 25 +++++- src/types/nativeWindowsMedia.ts | 1 + 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/native/src/windows_capture.cpp b/native/src/windows_capture.cpp index 77b3e3d..7bd2765 100644 --- a/native/src/windows_capture.cpp +++ b/native/src/windows_capture.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -252,6 +253,98 @@ Napi::Object createWindowsMediaSupport( return support; } +static const char kBase64Chars[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +std::string base64Encode(const std::vector& data) { + std::string result; + result.reserve(((data.size() + 2) / 3) * 4); + for (size_t i = 0; i < data.size(); i += 3) { + const uint32_t b0 = data[i]; + const uint32_t b1 = (i + 1 < data.size()) ? data[i + 1] : 0u; + const uint32_t b2 = (i + 2 < data.size()) ? data[i + 2] : 0u; + result += kBase64Chars[(b0 >> 2) & 0x3F]; + result += kBase64Chars[((b0 << 4) | (b1 >> 4)) & 0x3F]; + result += (i + 1 < data.size()) ? kBase64Chars[((b1 << 2) | (b2 >> 6)) & 0x3F] : '='; + result += (i + 2 < data.size()) ? kBase64Chars[b2 & 0x3F] : '='; + } + return result; +} + +// Thumbnail is fetched on a background thread to avoid blocking the NAPI call +// thread with cross-process WinRT async I/O. +std::mutex s_thumbMutex; +std::string s_thumbTrackKey; +std::string s_thumbDataUrl; +bool s_thumbFetching = false; + +void launchThumbnailFetch( + const std::string& trackKey, + winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionMediaProperties props) { + const auto thumbnailRef = props.Thumbnail(); + if (!thumbnailRef) { + std::lock_guard lock(s_thumbMutex); + s_thumbFetching = false; + return; + } + std::thread([trackKey, thumbnailRef]() { + std::string result; + try { + using winrt::Windows::Storage::Streams::DataReader; + const HRESULT hr = RoInitialize(RO_INIT_MULTITHREADED); + if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE) { + const auto stream = thumbnailRef.OpenReadAsync().get(); + const uint64_t size = stream.Size(); + if (size > 0 && size <= 4u * 1024u * 1024u) { + const auto reader = DataReader(stream); + const uint32_t loaded = reader.LoadAsync(static_cast(size)).get(); + if (loaded > 0) { + std::vector bytes(loaded); + reader.ReadBytes(bytes); + std::string mimeType = winrt::to_string(stream.ContentType()); + if (mimeType.empty()) { + mimeType = "image/jpeg"; + } + result = "data:" + mimeType + ";base64," + base64Encode(bytes); + } + } + if (SUCCEEDED(hr)) { + RoUninitialize(); + } + } + } catch (...) {} + std::lock_guard lock(s_thumbMutex); + if (s_thumbTrackKey == trackKey) { + s_thumbDataUrl = std::move(result); + } + s_thumbFetching = false; + }).detach(); +} + +std::string getOrFetchThumbnail( + const std::string& trackKey, + winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionMediaProperties props) { + bool shouldFetch = false; + std::string result; + { + std::lock_guard lock(s_thumbMutex); + if (s_thumbTrackKey == trackKey) { + result = s_thumbDataUrl; + } else { + s_thumbTrackKey = trackKey; + s_thumbDataUrl.clear(); + if (!s_thumbFetching) { + s_thumbFetching = true; + shouldFetch = true; + } + } + } + if (shouldFetch) { + launchThumbnailFetch(trackKey, std::move(props)); + } + return result; +} + std::string getDeviceId(IMMDevice* device) { if (device == nullptr) { return {}; @@ -1032,6 +1125,15 @@ Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) payload.Set( "sourceAppUserModelId", Napi::String::New(env, winrt::to_string(session->SourceAppUserModelId()))); + + const std::string trackKey = + winrt::to_string(mediaProperties.Title()) + "\n" + + winrt::to_string(mediaProperties.Artist()); + const std::string artworkDataUrl = getOrFetchThumbnail(trackKey, mediaProperties); + payload.Set( + "artworkDataUrl", + artworkDataUrl.empty() ? env.Null() : Napi::String::New(env, artworkDataUrl)); + return payload; } catch (const winrt::hresult_error& error) { Napi::Error::New( diff --git a/src/main/services/macSpotifyProvider.ts b/src/main/services/macSpotifyProvider.ts index 53343ec..f1c06f7 100644 --- a/src/main/services/macSpotifyProvider.ts +++ b/src/main/services/macSpotifyProvider.ts @@ -363,6 +363,10 @@ function parseWindowsSpotifyStatusPayload( : 0, ) + const artworkUrl = typeof payload.artworkDataUrl === 'string' && payload.artworkDataUrl + ? payload.artworkDataUrl + : null + const currentTrack = title && artist ? { id: sourceAppUserModelId @@ -372,7 +376,7 @@ function parseWindowsSpotifyStatusPayload( artist, album, isFavorite: false, - artworkUrl: null, + artworkUrl, } satisfies LocalSpotifyTrackSnapshot : null @@ -1030,6 +1034,25 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> { return } + if (artworkUrl.startsWith('data:')) { + const currentSnapshot = this.currentSnapshot + if (!currentSnapshot || getArtworkKey( + currentSnapshot.currentTrack?.id ?? null, + currentSnapshot.currentTrack?.artworkUrl ?? null, + ) !== artworkKey) { + return + } + this.currentArtworkKey = artworkKey + this.currentArtworkDataUrl = artworkUrl + this.failedArtworkKey = null + this.state = { + ...this.state, + snapshot: toPublicSnapshot(currentSnapshot, artworkUrl), + } + this.emitState() + return + } + const response = await this.fetchImpl(artworkUrl).catch(() => null) if (!response || !response.ok) { this.failedArtworkKey = artworkKey diff --git a/src/types/nativeWindowsMedia.ts b/src/types/nativeWindowsMedia.ts index 5c2adec..c47b5a2 100644 --- a/src/types/nativeWindowsMedia.ts +++ b/src/types/nativeWindowsMedia.ts @@ -5,6 +5,7 @@ export type NativeWindowsMediaSupport = NativeCaptureSupport export interface NativeWindowsSpotifyPlaybackState { album: string + artworkDataUrl: string | null artist: string durationMs: number playbackStatus: string