From 83a3972d441ee196d8bc0ebc5e9ea0440607bc5d Mon Sep 17 00:00:00 2001 From: ownedbywuigi Date: Wed, 1 Apr 2026 15:59:21 +0100 Subject: [PATCH] Add native Windows media SMTC metadata publishing --- application/basilisk/base/content/browser.js | 216 ++++++++++++ toolkit/content/browser-content.js | 25 ++ widget/nsIWindowsUIUtils.idl | 28 +- widget/windows/WindowsUIUtils.cpp | 343 +++++++++++++++++++ 4 files changed, 611 insertions(+), 1 deletion(-) diff --git a/application/basilisk/base/content/browser.js b/application/basilisk/base/content/browser.js index 6c917c4271..81e47cd464 100644 --- a/application/basilisk/base/content/browser.js +++ b/application/basilisk/base/content/browser.js @@ -967,6 +967,10 @@ var gBrowserInit = { Services.obs.addObserver(gPluginHandler.NPAPIPluginCrashed, "plugin-crashed", false); window.addEventListener("AppCommand", HandleAppCommandEvent, true); + gBrowser.addEventListener("DOMAudioPlaybackStarted", HandleWindowsSMTCAudioPlaybackEvent, true); + gBrowser.addEventListener("DOMAudioPlaybackStopped", HandleWindowsSMTCAudioPlaybackEvent, true); + gBrowser.tabContainer.addEventListener("TabAttrModified", HandleWindowsSMTCTabAttrModified, false); + Services.obs.addObserver(HandleSMTCControlCommand, "smtc-control-command", false); // These routines add message listeners. They must run before // loading the frame script to ensure that we don't miss any @@ -1453,6 +1457,11 @@ var gBrowserInit = { FullScreen.uninit(); Services.obs.removeObserver(gPluginHandler.NPAPIPluginCrashed, "plugin-crashed"); + window.removeEventListener("AppCommand", HandleAppCommandEvent, true); + gBrowser.removeEventListener("DOMAudioPlaybackStarted", HandleWindowsSMTCAudioPlaybackEvent, true); + gBrowser.removeEventListener("DOMAudioPlaybackStopped", HandleWindowsSMTCAudioPlaybackEvent, true); + gBrowser.tabContainer.removeEventListener("TabAttrModified", HandleWindowsSMTCTabAttrModified, false); + Services.obs.removeObserver(HandleSMTCControlCommand, "smtc-control-command"); try { gBrowser.removeProgressListener(window.XULBrowserWindow); @@ -1690,6 +1699,22 @@ function HandleAppCommandEvent(evt) { case "SendMail": MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser); break; + case "MediaPlayPause": + if (!HandleMediaControlAppCommand("playpause")) + return; + break; + case "MediaStop": + if (!HandleMediaControlAppCommand("stop")) + return; + break; + case "MediaNextTrack": + if (!HandleMediaControlAppCommand("next")) + return; + break; + case "MediaPrevTrack": + if (!HandleMediaControlAppCommand("previous")) + return; + break; default: return; } @@ -1697,6 +1722,197 @@ function HandleAppCommandEvent(evt) { evt.preventDefault(); } +function GetActiveMediaTab() { + if (gBrowser.selectedTab && + (gBrowser.selectedTab.soundPlaying || gBrowser.selectedTab.linkedBrowser.audioBlocked)) { + return gBrowser.selectedTab; + } + + for (let tab of gBrowser.visibleTabs) { + if (tab.soundPlaying || tab.linkedBrowser.audioBlocked) { + return tab; + } + } + + return gBrowser.selectedTab || null; +} + +function HandleMediaControlAppCommand(action) { + let tab = GetActiveMediaTab(); + if (!tab || !tab.linkedBrowser) { + return false; + } + + let browser = tab.linkedBrowser; + switch (action) { + case "playpause": + if (tab.soundPlaying && !browser.audioBlocked) { + browser.pauseMedia(true); + } else { + browser.resumeMedia(); + } + return true; + case "stop": + browser.stopMedia(); + return true; + case "next": + browser.messageManager.sendAsyncMessage("MediaControl:Key", { key: "MediaTrackNext" }); + return true; + case "previous": + browser.messageManager.sendAsyncMessage("MediaControl:Key", { key: "MediaTrackPrevious" }); + return true; + default: + return false; + } +} + +function getWindowsUIUtils() { + if (AppConstants.platform != "win") { + return null; + } + try { + return Cc["@mozilla.org/windows-ui-utils;1"] + .getService(Ci.nsIWindowsUIUtils); + } catch (e) { + return null; + } +} + +var gLastSMTCMediaTab = null; + +function isSMTCTabOpen(aTab) { + if (!aTab || !gBrowser || !gBrowser.tabs) { + return false; + } + for (let tab of gBrowser.tabs) { + if (tab == aTab) { + return true; + } + } + return false; +} + +function updateWindowsSMTCMetadataFromTab(aTab, aPlaying) { + let uiUtils = getWindowsUIUtils(); + if (!uiUtils) { + return; + } + + let tab = aTab; + if (!tab && isSMTCTabOpen(gLastSMTCMediaTab)) { + tab = gLastSMTCMediaTab; + } + + if (!tab) { + gLastSMTCMediaTab = null; + uiUtils.clearSMTCMetadata(); + return; + } + + let title = tab.label || ""; + let artist = ""; + let album = ""; + let thumbnailURL = ""; + + try { + let browser = tab.linkedBrowser; + if (browser && browser.currentURI) { + album = browser.currentURI.host || browser.currentURI.spec || ""; + + let spec = browser.currentURI.spec || ""; + let host = (browser.currentURI.host || "").toLowerCase(); + if (host == "www.youtube.com" || host == "youtube.com" || host == "m.youtube.com") { + let match = spec.match(/[?&]v=([^&]+)/); + if (match && match[1]) { + thumbnailURL = "https://i.ytimg.com/vi/" + match[1] + "/hqdefault.jpg"; + } + } else if (host == "youtu.be") { + let path = browser.currentURI.pathQueryRef || ""; + let id = path.replace(/^\//, "").split(/[?#]/)[0]; + if (id) { + thumbnailURL = "https://i.ytimg.com/vi/" + id + "/hqdefault.jpg"; + } + } + } + } catch (e) { + // Best-effort metadata extraction. + } + + uiUtils.setSMTCMetadata(title, artist, album); + if (thumbnailURL) { + uiUtils.setSMTCThumbnailURL(thumbnailURL); + } + uiUtils.setSMTCPlaybackState(!!aPlaying); + gLastSMTCMediaTab = tab; +} + +function HandleWindowsSMTCAudioPlaybackEvent(aEvent) { + let tab = gBrowser.getTabForBrowser(aEvent.target); + if (!tab) { + return; + } + + if (aEvent.type == "DOMAudioPlaybackStarted") { + updateWindowsSMTCMetadataFromTab(tab, true); + return; + } + + if (aEvent.type == "DOMAudioPlaybackStopped") { + let activeTab = GetActiveMediaTab(); + if (activeTab && activeTab.soundPlaying) { + updateWindowsSMTCMetadataFromTab(activeTab, true); + } else { + updateWindowsSMTCMetadataFromTab(null, false); + } + } +} + +function HandleWindowsSMTCTabAttrModified(aEvent) { + let changed = aEvent.detail && aEvent.detail.changed; + if (!changed || (changed.indexOf("soundplaying") < 0 && + changed.indexOf("blocked") < 0 && + changed.indexOf("muted") < 0 && + changed.indexOf("label") < 0)) { + return; + } + + let activeTab = GetActiveMediaTab(); + if (activeTab && activeTab.soundPlaying) { + updateWindowsSMTCMetadataFromTab(activeTab, true); + } else { + updateWindowsSMTCMetadataFromTab(null, false); + } +} + +function HandleSMTCControlCommand(subject, topic, data) { + if (topic != "smtc-control-command") { + return; + } + + let tab = (GetActiveMediaTab() && GetActiveMediaTab().soundPlaying) ? + GetActiveMediaTab() : + (isSMTCTabOpen(gLastSMTCMediaTab) ? gLastSMTCMediaTab : null); + if (!tab || !tab.linkedBrowser) { + return; + } + + let browser = tab.linkedBrowser; + switch (data) { + case "play": + browser.resumeMedia(); + updateWindowsSMTCMetadataFromTab(tab, true); + break; + case "pause": + browser.pauseMedia(true); + updateWindowsSMTCMetadataFromTab(tab, false); + break; + case "stop": + browser.stopMedia(); + updateWindowsSMTCMetadataFromTab(tab, false); + break; + } +} + function gotoHistoryIndex(aEvent) { let index = aEvent.target.getAttribute("index"); if (!index) diff --git a/toolkit/content/browser-content.js b/toolkit/content/browser-content.js index 954848cb80..3bf3def1ae 100644 --- a/toolkit/content/browser-content.js +++ b/toolkit/content/browser-content.js @@ -1017,6 +1017,31 @@ var AudioPlaybackListener = { }; AudioPlaybackListener.init(); +addMessageListener("MediaControl:Key", function(msg) { + let targetWindow = content; + if (!targetWindow || !targetWindow.document) { + return; + } + + let key = msg.data && msg.data.key; + if (!key) { + return; + } + + let eventInit = { + key, + code: key, + bubbles: true, + cancelable: true, + }; + + let downEvent = new targetWindow.KeyboardEvent("keydown", eventInit); + targetWindow.document.dispatchEvent(downEvent); + + let upEvent = new targetWindow.KeyboardEvent("keyup", eventInit); + targetWindow.document.dispatchEvent(upEvent); +}); + addMessageListener("Browser:PurgeSessionHistory", function BrowserPurgeHistory() { let sessionHistory = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory; if (!sessionHistory) { diff --git a/widget/nsIWindowsUIUtils.idl b/widget/nsIWindowsUIUtils.idl index 331562a672..9cfb405d11 100644 --- a/widget/nsIWindowsUIUtils.idl +++ b/widget/nsIWindowsUIUtils.idl @@ -7,7 +7,7 @@ #include "nsISupports.idl" -[scriptable, uuid(aa8a0ecf-96a1-418c-b80e-f24ae18bbedc)] +[scriptable, uuid(8eb5da7b-ec94-4cf4-9f2e-9d2b8f8bc763)] interface nsIWindowsUIUtils : nsISupports { /** @@ -20,5 +20,31 @@ interface nsIWindowsUIUtils : nsISupports * Update the tablet mode state */ void updateTabletModeState(); + + /** + * Update native Windows System Media Transport Controls playback state. + * No-op on unsupported Windows versions. + */ + void setSMTCPlaybackState(in boolean aPlaying); + + /** + * Publish media metadata to native Windows System Media Transport Controls. + * No-op on unsupported Windows versions. + */ + void setSMTCMetadata(in AString aTitle, + in AString aArtist, + in AString aAlbum); + + /** + * Publish artwork URL to native Windows System Media Transport Controls. + * No-op on unsupported Windows versions. + */ + void setSMTCThumbnailURL(in AString aThumbnailURL); + + /** + * Clear native Windows System Media Transport Controls metadata. + * No-op on unsupported Windows versions. + */ + void clearSMTCMetadata(); }; diff --git a/widget/windows/WindowsUIUtils.cpp b/widget/windows/WindowsUIUtils.cpp index 2235118597..78ac39b93c 100644 --- a/widget/windows/WindowsUIUtils.cpp +++ b/widget/windows/WindowsUIUtils.cpp @@ -25,12 +25,18 @@ #ifndef __MINGW32__ #include +#include +#include +#include +#include #pragma comment(lib, "runtimeobject.lib") using namespace mozilla; using namespace ABI::Windows::UI; using namespace ABI::Windows::UI::ViewManagement; +using namespace ABI::Windows::Media; +using namespace ABI::Windows::Storage::Streams; using namespace Microsoft::WRL; using namespace Microsoft::WRL::Wrappers; using namespace ABI::Windows::Foundation; @@ -57,6 +63,32 @@ namespace ABI { #define RuntimeClass_Windows_UI_ViewManagement_UIViewSettings L"Windows.UI.ViewManagement.UIViewSettings" #endif +#ifndef RuntimeClass_Windows_Media_SystemMediaTransportControls +#define RuntimeClass_Windows_Media_SystemMediaTransportControls L"Windows.Media.SystemMediaTransportControls" +#endif + +#ifndef RuntimeClass_Windows_Foundation_Uri +#define RuntimeClass_Windows_Foundation_Uri L"Windows.Foundation.Uri" +#endif + +#ifndef RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference +#define RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference L"Windows.Storage.Streams.RandomAccessStreamReference" +#endif + +// Some SDK configurations only forward-declare this interface unless newer +// NTDDI guards are enabled. Provide a local fallback definition. +#ifndef __ISystemMediaTransportControlsInterop_INTERFACE_DEFINED__ +MIDL_INTERFACE("ddb0472d-c911-4a1f-86d9-dc3d71a95f5a") +ISystemMediaTransportControlsInterop : public IInspectable +{ +public: + virtual HRESULT STDMETHODCALLTYPE GetForWindow( + HWND appWindow, + REFIID riid, + void** mediaTransportControl) = 0; +}; +#endif + #if WINVER_MAXVER < 0x0A00 namespace ABI { namespace Windows { @@ -91,6 +123,151 @@ public: #endif +#ifndef __MINGW32__ +namespace { + ComPtr gSMTC; + EventRegistrationToken gSMTCButtonPressedToken; + + void + NotifySMTCControlCommand(const char16_t* aCommand) + { + if (!aCommand) { + return; + } + nsCOMPtr observerService = mozilla::services::GetObserverService(); + if (observerService) { + observerService->NotifyObservers(nullptr, "smtc-control-command", aCommand); + } + } + + nsresult + GetHiddenWindowHWND(HWND* aHwnd) + { + if (!aHwnd) { + return NS_ERROR_INVALID_ARG; + } + *aHwnd = nullptr; + + nsCOMPtr appShell(do_GetService(NS_APPSHELLSERVICE_CONTRACTID)); + if (!appShell) { + return NS_ERROR_FAILURE; + } + + nsCOMPtr hiddenWindow; + nsresult rv = appShell->GetHiddenWindow(getter_AddRefs(hiddenWindow)); + NS_ENSURE_SUCCESS(rv, rv); + NS_ENSURE_TRUE(hiddenWindow, NS_ERROR_FAILURE); + + nsCOMPtr docShell; + rv = hiddenWindow->GetDocShell(getter_AddRefs(docShell)); + NS_ENSURE_SUCCESS(rv, rv); + NS_ENSURE_TRUE(docShell, NS_ERROR_FAILURE); + + nsCOMPtr baseWindow(do_QueryInterface(docShell)); + NS_ENSURE_TRUE(baseWindow, NS_ERROR_FAILURE); + + nsCOMPtr widget; + baseWindow->GetMainWidget(getter_AddRefs(widget)); + NS_ENSURE_TRUE(widget, NS_ERROR_FAILURE); + + *aHwnd = reinterpret_cast(widget->GetNativeData(NS_NATIVE_WINDOW)); + return *aHwnd ? NS_OK : NS_ERROR_FAILURE; + } + + nsresult + GetSMTCTargetHWND(HWND* aHwnd) + { + if (!aHwnd) { + return NS_ERROR_INVALID_ARG; + } + *aHwnd = nullptr; + + // Prefer an interactive browser window for SMTC interop. + HWND hwnd = ::GetForegroundWindow(); + if (!hwnd) { + hwnd = ::GetActiveWindow(); + } + if (hwnd) { + *aHwnd = hwnd; + return NS_OK; + } + + // Fall back to hidden window if no foreground window is available. + return GetHiddenWindowHWND(aHwnd); + } + + nsresult + EnsureSMTC() + { + if (gSMTC) { + return NS_OK; + } + if (!IsWin10OrLater()) { + return NS_OK; + } + + HWND hwnd = nullptr; + nsresult rv = GetSMTCTargetHWND(&hwnd); + NS_ENSURE_SUCCESS(rv, rv); + + ComPtr interop; + HRESULT hr = GetActivationFactory( + HStringReference(RuntimeClass_Windows_Media_SystemMediaTransportControls).Get(), + &interop); + if (FAILED(hr)) { + return NS_ERROR_NOT_AVAILABLE; + } + + hr = interop->GetForWindow(hwnd, IID_PPV_ARGS(&gSMTC)); + if (FAILED(hr)) { + return NS_ERROR_NOT_AVAILABLE; + } + + gSMTC->put_IsEnabled(TRUE); + gSMTC->put_IsPlayEnabled(TRUE); + gSMTC->put_IsPauseEnabled(TRUE); + gSMTC->put_IsStopEnabled(TRUE); + gSMTC->put_IsNextEnabled(TRUE); + gSMTC->put_IsPreviousEnabled(TRUE); + + auto buttonHandler = Callback< + __FITypedEventHandler_2_Windows__CMedia__CSystemMediaTransportControls_Windows__CMedia__CSystemMediaTransportControlsButtonPressedEventArgs>( + [](ABI::Windows::Media::ISystemMediaTransportControls*, + ABI::Windows::Media::ISystemMediaTransportControlsButtonPressedEventArgs* aArgs) -> HRESULT { + if (!aArgs) { + return S_OK; + } + + SystemMediaTransportControlsButton button; + if (FAILED(aArgs->get_Button(&button))) { + return S_OK; + } + + switch (button) { + case SystemMediaTransportControlsButton_Play: + NotifySMTCControlCommand(u"play"); + break; + case SystemMediaTransportControlsButton_Pause: + NotifySMTCControlCommand(u"pause"); + break; + case SystemMediaTransportControlsButton_Stop: + NotifySMTCControlCommand(u"stop"); + break; + default: + break; + } + return S_OK; + }); + + if (buttonHandler) { + gSMTC->add_ButtonPressed(buttonHandler.Get(), &gSMTCButtonPressedToken); + } + + return NS_OK; + } +} +#endif + WindowsUIUtils::WindowsUIUtils() : mInTabletMode(eTabletModeUnknown) { @@ -180,3 +357,169 @@ WindowsUIUtils::UpdateTabletModeState() return NS_OK; } +NS_IMETHODIMP +WindowsUIUtils::SetSMTCPlaybackState(bool aPlaying) +{ +#ifndef __MINGW32__ + if (!IsWin10OrLater()) { + return NS_OK; + } + + nsresult rv = EnsureSMTC(); + if (NS_FAILED(rv)) { + return NS_OK; + } + if (!gSMTC) { + return NS_OK; + } + + if (aPlaying) { + gSMTC->put_IsEnabled(TRUE); + gSMTC->put_PlaybackStatus(MediaPlaybackStatus_Playing); + } else { + gSMTC->put_IsEnabled(TRUE); + gSMTC->put_PlaybackStatus(MediaPlaybackStatus_Paused); + } +#endif + return NS_OK; +} + +NS_IMETHODIMP +WindowsUIUtils::SetSMTCMetadata(const nsAString& aTitle, + const nsAString& aArtist, + const nsAString& aAlbum) +{ +#ifndef __MINGW32__ + if (!IsWin10OrLater()) { + return NS_OK; + } + + nsresult rv = EnsureSMTC(); + if (NS_FAILED(rv)) { + return NS_OK; + } + if (!gSMTC) { + return NS_OK; + } + + ComPtr updater; + HRESULT hr = gSMTC->get_DisplayUpdater(&updater); + if (FAILED(hr) || !updater) { + return NS_OK; + } + + updater->put_Type(MediaPlaybackType_Music); + + ComPtr music; + hr = updater->get_MusicProperties(&music); + if (FAILED(hr) || !music) { + return NS_OK; + } + + nsAutoString titleString(aTitle); + nsAutoString artistString(aArtist); + nsAutoString albumString(aAlbum); + + HString title; + HString artist; + HString album; + title.Set(titleString.get(), static_cast(titleString.Length())); + artist.Set(artistString.get(), static_cast(artistString.Length())); + album.Set(albumString.get(), static_cast(albumString.Length())); + + music->put_Title(title.Get()); + music->put_Artist(artist.Get()); + music->put_AlbumArtist(album.Get()); + + updater->Update(); +#endif + return NS_OK; +} + +NS_IMETHODIMP +WindowsUIUtils::SetSMTCThumbnailURL(const nsAString& aThumbnailURL) +{ +#ifndef __MINGW32__ + if (!IsWin10OrLater()) { + return NS_OK; + } + + nsresult rv = EnsureSMTC(); + if (NS_FAILED(rv)) { + return NS_OK; + } + if (!gSMTC || aThumbnailURL.IsEmpty()) { + return NS_OK; + } + + ComPtr updater; + HRESULT hr = gSMTC->get_DisplayUpdater(&updater); + if (FAILED(hr) || !updater) { + return NS_OK; + } + + ComPtr uriFactory; + hr = GetActivationFactory(HStringReference(RuntimeClass_Windows_Foundation_Uri).Get(), &uriFactory); + if (FAILED(hr) || !uriFactory) { + return NS_OK; + } + + nsAutoString urlString(aThumbnailURL); + HString url; + url.Set(urlString.get(), static_cast(urlString.Length())); + + ComPtr uri; + hr = uriFactory->CreateUri(url.Get(), &uri); + if (FAILED(hr) || !uri) { + return NS_OK; + } + + ComPtr streamRefStatics; + hr = GetActivationFactory( + HStringReference(RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference).Get(), + &streamRefStatics); + if (FAILED(hr) || !streamRefStatics) { + return NS_OK; + } + + ComPtr thumbnail; + hr = streamRefStatics->CreateFromUri(uri.Get(), &thumbnail); + if (FAILED(hr) || !thumbnail) { + return NS_OK; + } + + updater->put_Thumbnail(thumbnail.Get()); + updater->Update(); +#endif + return NS_OK; +} + +NS_IMETHODIMP +WindowsUIUtils::ClearSMTCMetadata() +{ +#ifndef __MINGW32__ + if (!IsWin10OrLater()) { + return NS_OK; + } + + nsresult rv = EnsureSMTC(); + if (NS_FAILED(rv)) { + return NS_OK; + } + if (!gSMTC) { + return NS_OK; + } + + ComPtr updater; + HRESULT hr = gSMTC->get_DisplayUpdater(&updater); + if (SUCCEEDED(hr) && updater) { + updater->ClearAll(); + updater->Update(); + } + + gSMTC->put_PlaybackStatus(MediaPlaybackStatus_Stopped); + gSMTC->put_IsEnabled(FALSE); +#endif + return NS_OK; +} +