From 383088054c9e2fecad1efac96a2f7c3fb1a41d64 Mon Sep 17 00:00:00 2001 From: Gary Wang Date: Mon, 3 Aug 2026 20:39:54 +0800 Subject: [PATCH] feat(windows): SMTC integration --- .github/workflows/windows.yml | 6 +- CMakeLists.txt | 3 + appveyor.yml | 4 +- main.cpp | 17 ++ mainwindow.cpp | 62 ++++- mainwindow.h | 2 + winmediacontrol.cpp | 421 ++++++++++++++++++++++++++++++++++ winmediacontrol.h | 42 ++++ 8 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 winmediacontrol.cpp create mode 100644 winmediacontrol.h diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index f7cb81e..9ccdd55 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: include: - - qt_ver: '6.10.1' + - qt_ver: '6.10.3' vs: '2022' aqt_arch: 'win64_msvc2022_64' msvc_arch: 'x64' @@ -16,7 +16,7 @@ jobs: runs-on: windows-2022 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Qt uses: jurplel/install-qt-action@v4 with: @@ -68,7 +68,7 @@ jobs: if ErrorLevel 8 (exit /B 1) copy LICENSE build/bin/ exit /B 0 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: "${{ matrix.aqt_arch }}-qt${{ matrix.qt_ver }}-cmake-package" path: build/bin/* diff --git a/CMakeLists.txt b/CMakeLists.txt index 34ecf96..faadbc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ set (PMUSIC_CPP_FILES fftspectrum.cpp playbackprogressindicator.cpp lyricswidget.cpp + winmediacontrol.cpp ) set (PMUSIC_HEADER_FILES @@ -60,6 +61,7 @@ set (PMUSIC_HEADER_FILES fftspectrum.h playbackprogressindicator.h taskbarmanager.h + winmediacontrol.h lyricswidget.h ) @@ -88,6 +90,7 @@ TS_FILES if (WIN32) target_sources(${EXE_NAME} PRIVATE assets/pineapple-music.rc) target_sources(${EXE_NAME} PRIVATE taskbarmanager.cpp) + target_link_libraries(${EXE_NAME} PRIVATE runtimeobject ole32) else () target_sources(${EXE_NAME} PRIVATE taskbarmanager_dummy.cpp) endif () diff --git a/appveyor.yml b/appveyor.yml index e5a9bfb..13d9bd5 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,8 +4,8 @@ environment: CMAKE_INSTALL_ROOT: C:\projects\cmake PACKAGE_INSTALL_ROOT: C:\projects\pir matrix: - - build_name: mingw1120_64_qt6_7 - QTPATH: C:\Qt\6.8\mingw_64 + - build_name: mingw1120_64_qt6_9 + QTPATH: C:\Qt\6.9\mingw_64 MINGW64: C:\Qt\mingw_64\Tools\mingw1310_64 install: diff --git a/main.cpp b/main.cpp index c6580a5..a73b890 100644 --- a/main.cpp +++ b/main.cpp @@ -13,8 +13,25 @@ #include #include +#ifdef Q_OS_WIN +#include +#endif + int main(int argc, char *argv[]) { +#ifdef Q_OS_WIN + typedef HRESULT(WINAPI *SetCurrentProcessExplicitAppUserModelIDProc)(PCWSTR); + HMODULE shell32 = LoadLibraryW(L"shell32.dll"); + if (shell32) { + auto proc = reinterpret_cast( + GetProcAddress(shell32, "SetCurrentProcessExplicitAppUserModelID")); + if (proc) { + proc(L"net.blumia.pineapple-music"); + } + FreeLibrary(shell32); + } +#endif + QApplication a(argc, argv); QTranslator translator; diff --git a/mainwindow.cpp b/mainwindow.cpp index f8437a5..3caf7f3 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Gary Wang +// SPDX-FileCopyrightText: 2026 Gary Wang // // SPDX-License-Identifier: MIT @@ -9,6 +9,7 @@ #include "fftspectrum.h" #include "lrcbar.h" #include "taskbarmanager.h" +#include "winmediacontrol.h" #include "lyricsmanager.h" #include "lyricswidget.h" @@ -39,6 +40,7 @@ #include #include #include +#include constexpr QSize miniSize(490, 160); constexpr QSize fullSize(490, 420); @@ -55,6 +57,7 @@ MainWindow::MainWindow(QWidget *parent) , m_lyricsWidget(new LyricsWidget(this)) , m_playlistManager(new PlaylistManager(this)) , m_taskbarManager(new TaskBarManager(this)) + , m_mediaControl(new WinMediaControl(this)) { ui->setupUi(this); m_lyricsWidget->setLyricsManager(m_lyricsManager); @@ -98,7 +101,9 @@ MainWindow::MainWindow(QWidget *parent) centerWindow(); QTimer::singleShot(1000, [this](){ - m_taskbarManager->setWinId(window()->winId()); + auto winId = window()->winId(); + m_taskbarManager->setWinId(winId); + m_mediaControl->initialize(reinterpret_cast(winId)); }); } @@ -163,15 +168,23 @@ void MainWindow::setAudioMetadataForDisplay(QString title, QString artist, QStri { Q_UNUSED(album); + if (title.isEmpty()) { + title = QFileInfo(m_mediaPlayer->source().fileName()).completeBaseName(); + } + if (!title.isEmpty()) { if (!artist.isEmpty()) { - ui->titleLabel->setText(QString("%1 - %2").arg(artist, title)); + title = QString("%1 - %2").arg(artist, title); } else if (!album.isEmpty()) { - ui->titleLabel->setText(QString("%1 - %2").arg(album, title)); + title = QString("%1 - %2").arg(album, title); } else { - ui->titleLabel->setText(QString("%1").arg(title)); + title = QString("%1").arg(title); } } + + ui->titleLabel->setText(title); + + m_mediaControl->setMetadata(title, artist, album); } void MainWindow::localSocketPlayAudioFiles(QVariant audioFilesVariant) @@ -510,10 +523,14 @@ void MainWindow::initConnections() } QVariant coverArt(metadata.value(QMediaMetaData::ThumbnailImage)); if (!coverArt.isNull()) { - ui->coverLabel->setPixmap(QPixmap::fromImage(coverArt.value())); + QImage coverImage = coverArt.value(); + ui->coverLabel->setPixmap(QPixmap::fromImage(coverImage)); + m_mediaControl->setCoverArt(coverImage); } else { qDebug() << "No ThumbnailImage!" << metadata.keys(); ui->coverLabel->setPixmap(QPixmap(":/icons/icons/media-album-cover.svg")); + QIcon appIcon(QStringLiteral(":/icons/icons/app-icon.svg")); + m_mediaControl->setCoverArt(appIcon.pixmap(QSize(256, 256)).toImage()); } }); connect(m_playlistManager, &PlaylistManager::currentIndexChanged, this, [=](int index){ @@ -525,6 +542,7 @@ void MainWindow::initConnections() if (m_mediaPlayer->duration() != 0) { ui->playbackProgressIndicator->setPosition(pos); m_taskbarManager->setProgressValue(pos); + m_mediaControl->setPosition(pos, m_mediaPlayer->duration()); } if (m_lrcbar->isVisible() || m_lyricsWidget->isVisible()) { @@ -568,10 +586,15 @@ void MainWindow::initConnections() switch (newState) { case QMediaPlayer::PlayingState: ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-pause.png")); + m_mediaControl->setPlaybackState(1); break; case QMediaPlayer::StoppedState: + ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-start.png")); + m_mediaControl->setPlaybackState(0); + break; case QMediaPlayer::PausedState: ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-start.png")); + m_mediaControl->setPlaybackState(2); break; } m_taskbarManager->setPlaybackState(newState); @@ -627,6 +650,33 @@ void MainWindow::initConnections() on_nextBtn_clicked(); }); + connect(m_mediaControl, &WinMediaControl::playRequested, this, [this](){ + if (QList {QMediaPlayer::PausedState, QMediaPlayer::StoppedState} + .contains(m_mediaPlayer->playbackState())) { + m_mediaPlayer->play(); + } + }); + + connect(m_mediaControl, &WinMediaControl::pauseRequested, this, [this](){ + m_mediaPlayer->pause(); + }); + + connect(m_mediaControl, &WinMediaControl::stopRequested, this, [this](){ + m_mediaPlayer->stop(); + }); + + connect(m_mediaControl, &WinMediaControl::nextRequested, this, [this](){ + on_nextBtn_clicked(); + }); + + connect(m_mediaControl, &WinMediaControl::previousRequested, this, [this](){ + on_prevBtn_clicked(); + }); + + connect(m_mediaControl, &WinMediaControl::seekRequested, this, [this](qint64 positionMs){ + m_mediaPlayer->setPosition(positionMs); + }); + connect(m_mediaPlayer, &QMediaPlayer::errorOccurred, this, [=](QMediaPlayer::Error error, const QString &errorString) { qDebug() << error << errorString; }); diff --git a/mainwindow.h b/mainwindow.h index 3ce30e1..81396c4 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -25,6 +25,7 @@ class LyricsManager; class LyricsWidget; class PlaylistManager; class TaskBarManager; +class WinMediaControl; class MainWindow : public QMainWindow { Q_OBJECT @@ -112,6 +113,7 @@ private: QPropertyAnimation *m_fadeOutAnimation; PlaylistManager *m_playlistManager; TaskBarManager *m_taskbarManager; + WinMediaControl *m_mediaControl; void initUiAndAnimation(); void initConnections(); diff --git a/winmediacontrol.cpp b/winmediacontrol.cpp new file mode 100644 index 0000000..2f2f466 --- /dev/null +++ b/winmediacontrol.cpp @@ -0,0 +1,421 @@ +// SPDX-FileCopyrightText: 2026 Gary Wang +// +// SPDX-License-Identifier: MIT + +#include "winmediacontrol.h" + +#include +#include + +#ifdef Q_OS_WIN + +#include +#include +#include +#include + +#include +#include + +using namespace Microsoft::WRL; +using namespace Microsoft::WRL::Wrappers; +using namespace ABI::Windows::Media; +using namespace ABI::Windows::Foundation; +using namespace ABI::Windows::Storage::Streams; + +using ButtonPressedHandler = ITypedEventHandler; +using PositionChangeHandler = ITypedEventHandler; + +// --------------------------------------------------------------------------- +// COM event handler for button presses +// --------------------------------------------------------------------------- +class ButtonDelegate : public ButtonPressedHandler +{ +public: + ButtonDelegate(WinMediaControl* owner) : m_ref(1), m_owner(owner) {} + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override + { + if (!ppv) return E_POINTER; + if (riid == __uuidof(IUnknown) || riid == __uuidof(ButtonPressedHandler)) { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&m_ref); } + + ULONG STDMETHODCALLTYPE Release() override + { + ULONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return r; + } + + HRESULT STDMETHODCALLTYPE Invoke( + ISystemMediaTransportControls*, + ISystemMediaTransportControlsButtonPressedEventArgs* args) override + { + if (args) { + SystemMediaTransportControlsButton button; + if (SUCCEEDED(args->get_Button(&button))) { + switch (button) { + case SystemMediaTransportControlsButton_Play: + emit m_owner->playRequested(); + break; + case SystemMediaTransportControlsButton_Pause: + emit m_owner->pauseRequested(); + break; + case SystemMediaTransportControlsButton_Stop: + emit m_owner->stopRequested(); + break; + case SystemMediaTransportControlsButton_Next: + emit m_owner->nextRequested(); + break; + case SystemMediaTransportControlsButton_Previous: + emit m_owner->previousRequested(); + break; + default: + break; + } + } + } + return S_OK; + } + +private: + LONG m_ref; + WinMediaControl* m_owner; +}; + +// --------------------------------------------------------------------------- +// COM event handler for position change (seek) requests +// --------------------------------------------------------------------------- +class PositionChangeDelegate : public PositionChangeHandler +{ +public: + PositionChangeDelegate(WinMediaControl* owner) : m_ref(1), m_owner(owner) {} + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override + { + if (!ppv) return E_POINTER; + if (riid == __uuidof(IUnknown) || riid == __uuidof(PositionChangeHandler)) { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&m_ref); } + + ULONG STDMETHODCALLTYPE Release() override + { + ULONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return r; + } + + HRESULT STDMETHODCALLTYPE Invoke( + ISystemMediaTransportControls*, + IPlaybackPositionChangeRequestedEventArgs* args) override + { + if (args) { + TimeSpan ts; + if (SUCCEEDED(args->get_RequestedPlaybackPosition(&ts))) { + emit m_owner->seekRequested(ts.Duration / 10000); + } + } + return S_OK; + } + +private: + LONG m_ref; + WinMediaControl* m_owner; +}; + +// --------------------------------------------------------------------------- +// WinMediaControlPrivate +// --------------------------------------------------------------------------- +class WinMediaControlPrivate +{ +public: + ComPtr smtc; + ComPtr smtc2; + ComPtr updater; + ComPtr musicProps; + EventRegistrationToken buttonToken{}; + EventRegistrationToken positionChangeToken{}; + bool initialized = false; + + QString title; + QString artist; + QString album; + QImage coverArt; +}; + +WinMediaControl::WinMediaControl(QObject* parent) + : QObject(parent) + , d(new WinMediaControlPrivate()) +{ + HRESULT hr = RoInitialize(RO_INIT_SINGLETHREADED); + if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) { + qWarning() << "WinMediaControl: RoInitialize failed:" << Qt::hex << hr; + } +} + +WinMediaControl::~WinMediaControl() +{ + shutdown(); + delete d; +} + +void WinMediaControl::initialize(void* hwnd) +{ + if (d->initialized || !hwnd) return; + + HWND window = static_cast(hwnd); + + ComPtr interop; + HRESULT hr = RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_Media_SystemMediaTransportControls).Get(), + IID_PPV_ARGS(&interop)); + if (FAILED(hr) || !interop) { + qWarning() << "WinMediaControl: Failed to get interop factory:" << Qt::hex << hr; + return; + } + + hr = interop->GetForWindow(window, IID_PPV_ARGS(&d->smtc)); + if (FAILED(hr) || !d->smtc) { + qWarning() << "WinMediaControl: GetForWindow failed:" << Qt::hex << hr; + return; + } + + d->smtc->put_IsEnabled(true); + d->smtc->put_IsPlayEnabled(true); + d->smtc->put_IsPauseEnabled(true); + d->smtc->put_IsStopEnabled(true); + d->smtc->put_IsNextEnabled(true); + d->smtc->put_IsPreviousEnabled(true); + + d->smtc.As(&d->smtc2); + + d->smtc->get_DisplayUpdater(&d->updater); + if (d->updater) { + d->updater->put_Type(MediaPlaybackType_Music); + d->updater->get_MusicProperties(&d->musicProps); + d->updater->Update(); + } + + auto* btnHandler = new ButtonDelegate(this); + hr = d->smtc->add_ButtonPressed(btnHandler, &d->buttonToken); + btnHandler->Release(); + + if (d->smtc2) { + auto* posHandler = new PositionChangeDelegate(this); + hr = d->smtc2->add_PlaybackPositionChangeRequested(posHandler, &d->positionChangeToken); + posHandler->Release(); + } + + d->initialized = true; + qDebug() << "WinMediaControl: SMTC initialized successfully"; +} + +void WinMediaControl::shutdown() +{ + if (!d->initialized) return; + + if (d->smtc) { + d->smtc->remove_ButtonPressed(d->buttonToken); + if (d->smtc2) { + d->smtc2->remove_PlaybackPositionChangeRequested(d->positionChangeToken); + } + d->smtc->put_IsEnabled(false); + } + d->smtc.Reset(); + d->smtc2.Reset(); + d->updater.Reset(); + d->musicProps.Reset(); + d->initialized = false; +} + +static ComPtr imageToStreamReference(const QImage& image) +{ + QByteArray imageData; + QBuffer buffer(&imageData); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + buffer.close(); + + ComPtr insp; + HRESULT hr = RoActivateInstance( + HStringReference(RuntimeClass_Windows_Storage_Streams_InMemoryRandomAccessStream).Get(), + &insp); + if (FAILED(hr) || !insp) return nullptr; + + ComPtr stream; + insp.As(&stream); + if (!stream) return nullptr; + + ComPtr outputStream; + stream->GetOutputStreamAt(0, &outputStream); + + ComPtr writerFactory; + RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_Storage_Streams_DataWriter).Get(), + IID_PPV_ARGS(&writerFactory)); + if (!writerFactory) return nullptr; + + ComPtr writer; + writerFactory->CreateDataWriter(outputStream.Get(), &writer); + if (!writer) return nullptr; + + writer->WriteBytes( + static_cast(imageData.size()), + reinterpret_cast(imageData.data())); + + ComPtr> storeOp; + writer->StoreAsync(&storeOp); + if (storeOp) { + // For in-memory streams this completes synchronously; just drain it + UINT32 result = 0; + storeOp->GetResults(&result); + } + + stream->Seek(0); + + ComPtr rars; + RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference).Get(), + IID_PPV_ARGS(&rars)); + if (!rars) return nullptr; + + ComPtr ref; + rars->CreateFromStream(stream.Get(), &ref); + return ref; +} + +void WinMediaControl::setMetadata(const QString& title, const QString& artist, + const QString& album) +{ + d->title = title; + d->artist = artist; + d->album = album; + + if (!d->initialized || !d->updater || !d->musicProps) return; + + d->updater->put_Type(MediaPlaybackType_Music); + d->updater->get_MusicProperties(&d->musicProps); + + { + std::wstring wstr = title.toStdWString(); + d->musicProps->put_Title(HStringReference(wstr.c_str()).Get()); + } + { + std::wstring wstr = artist.toStdWString(); + d->musicProps->put_Artist(HStringReference(wstr.c_str()).Get()); + } + + ComPtr musicProps2; + if (SUCCEEDED(d->musicProps.As(&musicProps2)) && musicProps2) { + std::wstring wstr = album.toStdWString(); + musicProps2->put_AlbumTitle(HStringReference(wstr.c_str()).Get()); + } + + if (!d->coverArt.isNull()) { + auto thumbRef = imageToStreamReference(d->coverArt); + if (thumbRef) { + d->updater->put_Thumbnail(thumbRef.Get()); + } + } else { + d->updater->put_Thumbnail(nullptr); + } + + d->updater->Update(); +} + +void WinMediaControl::setCoverArt(const QImage& coverArt) +{ + d->coverArt = coverArt; + if (d->initialized) { + setMetadata(d->title, d->artist, d->album); + } +} + +void WinMediaControl::setPlaybackState(int state) +{ + if (!d->initialized || !d->smtc) return; + + MediaPlaybackStatus status; + switch (state) { + case 0: status = MediaPlaybackStatus_Stopped; break; + case 1: status = MediaPlaybackStatus_Playing; break; + case 2: status = MediaPlaybackStatus_Paused; break; + default: status = MediaPlaybackStatus_Stopped; break; + } + d->smtc->put_PlaybackStatus(status); +} + +void WinMediaControl::setPosition(qint64 positionMs, qint64 durationMs) +{ + if (!d->initialized || !d->smtc2) return; + + ComPtr insp; + HRESULT hr = RoActivateInstance( + HStringReference(RuntimeClass_Windows_Media_SystemMediaTransportControlsTimelineProperties).Get(), + &insp); + if (FAILED(hr) || !insp) return; + + ComPtr timeline; + insp.As(&timeline); + if (!timeline) return; + + timeline->put_StartTime({ 0 }); + timeline->put_EndTime({ durationMs * 10000 }); + timeline->put_Position({ positionMs * 10000 }); + timeline->put_MinSeekTime({ 0 }); + timeline->put_MaxSeekTime({ durationMs * 10000 }); + + d->smtc2->UpdateTimelineProperties(timeline.Get()); +} + +void WinMediaControl::setControlsEnabled(bool play, bool pause, bool stop, + bool next, bool previous) +{ + if (!d->initialized || !d->smtc) return; + + d->smtc->put_IsPlayEnabled(play); + d->smtc->put_IsPauseEnabled(pause); + d->smtc->put_IsStopEnabled(stop); + d->smtc->put_IsNextEnabled(next); + d->smtc->put_IsPreviousEnabled(previous); +} + +#else // Q_OS_WIN + +// Non-Windows stub implementations + +WinMediaControl::WinMediaControl(QObject* parent) + : QObject(parent) + , d(nullptr) +{ +} + +WinMediaControl::~WinMediaControl() = default; + +void WinMediaControl::initialize(void*) {} +void WinMediaControl::shutdown() {} + +void WinMediaControl::setMetadata(const QString&, const QString&, const QString&) {} +void WinMediaControl::setCoverArt(const QImage&) {} +void WinMediaControl::setPlaybackState(int) {} +void WinMediaControl::setPosition(qint64, qint64) {} +void WinMediaControl::setControlsEnabled(bool, bool, bool, bool, bool) {} + +#endif // Q_OS_WIN diff --git a/winmediacontrol.h b/winmediacontrol.h new file mode 100644 index 0000000..b750387 --- /dev/null +++ b/winmediacontrol.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Gary Wang +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +class WinMediaControlPrivate; + +class WinMediaControl : public QObject +{ + Q_OBJECT + +public: + explicit WinMediaControl(QObject* parent = nullptr); + ~WinMediaControl(); + + void initialize(void* hwnd); + void shutdown(); + + void setMetadata(const QString& title, const QString& artist, + const QString& album); + void setCoverArt(const QImage& coverArt); + void setPlaybackState(int state); + void setPosition(qint64 positionMs, qint64 durationMs); + void setControlsEnabled(bool play, bool pause, bool stop, + bool next, bool previous); + +signals: + void playRequested(); + void pauseRequested(); + void stopRequested(); + void nextRequested(); + void previousRequested(); + void seekRequested(qint64 positionMs); + +private: + WinMediaControlPrivate* d = nullptr; +};