6 Commits
16 changed files with 890 additions and 124 deletions
+4 -4
View File
@@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- qt_ver: '6.10.1' - qt_ver: '6.10.3'
vs: '2022' vs: '2022'
aqt_arch: 'win64_msvc2022_64' aqt_arch: 'win64_msvc2022_64'
msvc_arch: 'x64' msvc_arch: 'x64'
@@ -16,7 +16,7 @@ jobs:
runs-on: windows-2022 runs-on: windows-2022
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Install Qt - name: Install Qt
uses: jurplel/install-qt-action@v4 uses: jurplel/install-qt-action@v4
with: with:
@@ -63,12 +63,12 @@ jobs:
cmake --build build --config Release -j || goto :error cmake --build build --config Release -j || goto :error
cmake --build build --config Release --target=install || goto :error cmake --build build --config Release --target=install || goto :error
:: ------ pkg ------ :: ------ pkg ------
windeployqt --verbose=2 --no-quick-import --no-translations --no-opengl-sw --no-system-d3d-compiler --no-system-dxc-compiler --multimedia --core5compat --skip-plugin-types tls,networkinformation build\bin\pmusic.exe windeployqt --verbose=2 --no-quick-import --no-translations --no-opengl-sw --no-system-d3d-compiler --no-system-dxc-compiler --multimedia --skip-plugin-types tls,networkinformation build\bin\pmusic.exe
robocopy ./dependencies_bin/bin build/bin *.dll robocopy ./dependencies_bin/bin build/bin *.dll
if ErrorLevel 8 (exit /B 1) if ErrorLevel 8 (exit /B 1)
copy LICENSE build/bin/ copy LICENSE build/bin/
exit /B 0 exit /B 0
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v6
with: with:
name: "${{ matrix.aqt_arch }}-qt${{ matrix.qt_ver }}-cmake-package" name: "${{ matrix.aqt_arch }}-qt${{ matrix.qt_ver }}-cmake-package"
path: build/bin/* path: build/bin/*
+3
View File
@@ -6,6 +6,9 @@ build-*/
.vscode/ .vscode/
.idea/ .idea/
# Clangd cache
.cache/
# User config file # User config file
CMakeLists.txt.user* CMakeLists.txt.user*
+10 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.12) cmake_minimum_required(VERSION 3.12)
project(pineapple-music LANGUAGES CXX VERSION 0.4.0) project(pineapple-music LANGUAGES CXX VERSION 0.5.0)
include (GNUInstallDirs) include (GNUInstallDirs)
include (FeatureSummary) include (FeatureSummary)
@@ -47,6 +47,8 @@ set (PMUSIC_CPP_FILES
lyricsmanager.cpp lyricsmanager.cpp
fftspectrum.cpp fftspectrum.cpp
playbackprogressindicator.cpp playbackprogressindicator.cpp
lyricswidget.cpp
winmediacontrol.cpp
) )
set (PMUSIC_HEADER_FILES set (PMUSIC_HEADER_FILES
@@ -59,6 +61,8 @@ set (PMUSIC_HEADER_FILES
fftspectrum.h fftspectrum.h
playbackprogressindicator.h playbackprogressindicator.h
taskbarmanager.h taskbarmanager.h
winmediacontrol.h
lyricswidget.h
) )
set (PMUSIC_UI_FILES set (PMUSIC_UI_FILES
@@ -86,6 +90,7 @@ TS_FILES
if (WIN32) if (WIN32)
target_sources(${EXE_NAME} PRIVATE assets/pineapple-music.rc) target_sources(${EXE_NAME} PRIVATE assets/pineapple-music.rc)
target_sources(${EXE_NAME} PRIVATE taskbarmanager.cpp) target_sources(${EXE_NAME} PRIVATE taskbarmanager.cpp)
target_link_libraries(${EXE_NAME} PRIVATE runtimeobject ole32)
else () else ()
target_sources(${EXE_NAME} PRIVATE taskbarmanager_dummy.cpp) target_sources(${EXE_NAME} PRIVATE taskbarmanager_dummy.cpp)
endif () endif ()
@@ -113,6 +118,10 @@ if (USE_QTEXTCODEC)
target_link_libraries(${EXE_NAME} PRIVATE Qt6::Core5Compat) target_link_libraries(${EXE_NAME} PRIVATE Qt6::Core5Compat)
endif() endif()
target_compile_definitions(${EXE_NAME} PRIVATE
PMUSIC_VERSION_STRING="${CMAKE_PROJECT_VERSION}"
)
# Install settings # Install settings
if (WIN32) if (WIN32)
set_target_properties(${EXE_NAME} PROPERTIES set_target_properties(${EXE_NAME} PROPERTIES
+1 -1
View File
@@ -13,6 +13,7 @@ We have the following features:
- PotPlayer `.pbf` file, `[Bookmark]`s as chapters - PotPlayer `.pbf` file, `[Bookmark]`s as chapters
- Built-in chapter support (require build with FFmpeg) - Built-in chapter support (require build with FFmpeg)
- Auto-load all audio files in the same folder of the file that you attempted to play, into a playlist - Auto-load all audio files in the same folder of the file that you attempted to play, into a playlist
- [SMTC](https://learn.microsoft.com/en-us/uwp/api/windows.media.systemmediatransportcontrols) support on Windows
These features are not available, some of them are TBD and others are not planned: These features are not available, some of them are TBD and others are not planned:
@@ -21,7 +22,6 @@ These features are not available, some of them are TBD and others are not planne
- No music library management support and there won't be one! - No music library management support and there won't be one!
- It'll auto-load music files in the same folder of the file that you attempted to play, so organize your music files on a folder-basis. - It'll auto-load music files in the same folder of the file that you attempted to play, so organize your music files on a folder-basis.
- Limited system integration: - Limited system integration:
- No [SMTC](https://learn.microsoft.com/en-us/uwp/api/windows.media.systemmediatransportcontrols) support under Windows for now
- No [MPRIS](https://www.freedesktop.org/wiki/Specifications/mpris-spec/) support under Linux desktop for now - No [MPRIS](https://www.freedesktop.org/wiki/Specifications/mpris-spec/) support under Linux desktop for now
- Limited lyrics (`.lrc`) loading support: - Limited lyrics (`.lrc`) loading support:
- Currently no `.tlrc` (for translated lyrics) or `.rlrc` (for romanized lyrics) support. - Currently no `.tlrc` (for translated lyrics) or `.rlrc` (for romanized lyrics) support.
+2 -2
View File
@@ -4,8 +4,8 @@ environment:
CMAKE_INSTALL_ROOT: C:\projects\cmake CMAKE_INSTALL_ROOT: C:\projects\cmake
PACKAGE_INSTALL_ROOT: C:\projects\pir PACKAGE_INSTALL_ROOT: C:\projects\pir
matrix: matrix:
- build_name: mingw1120_64_qt6_7 - build_name: mingw1120_64_qt6_9
QTPATH: C:\Qt\6.8\mingw_64 QTPATH: C:\Qt\6.9\mingw_64
MINGW64: C:\Qt\mingw_64\Tools\mingw1310_64 MINGW64: C:\Qt\mingw_64\Tools\mingw1310_64
install: install:
+9 -11
View File
@@ -10,9 +10,9 @@
#include <QPainter> #include <QPainter>
#include <QWindow> #include <QWindow>
LrcBar::LrcBar(QWidget *parent) LrcBar::LrcBar(QWidget *parent, LyricsManager *mgr)
: QWidget(parent, Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool) : QWidget(parent, Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool)
, m_lrcMgr(new LyricsManager(this)) , m_lrcMgr(mgr)
{ {
m_font.setPointSize(30); m_font.setPointSize(30);
m_font.setStyleStrategy(QFont::PreferAntialias); m_font.setStyleStrategy(QFont::PreferAntialias);
@@ -32,9 +32,14 @@ LrcBar::LrcBar(QWidget *parent)
setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_TranslucentBackground);
setMouseTracking(true); setMouseTracking(true);
setGeometry(QRect(QPoint((qApp->primaryScreen()->geometry().width() - windowSize.width()) / 2, setGeometry(QRect(QPoint((qApp->primaryScreen()->availableGeometry().width() - windowSize.width()) / 2,
qApp->primaryScreen()->geometry().height() - windowSize.height() - 50), qApp->primaryScreen()->availableGeometry().bottom() - windowSize.height() - 5),
windowSize)); windowSize));
connect(m_lrcMgr, &LyricsManager::lyricsLoaded, this, [this](bool){
m_currentTimeMs = 0;
update();
});
} }
LrcBar::~LrcBar() LrcBar::~LrcBar()
@@ -42,18 +47,11 @@ LrcBar::~LrcBar()
} }
bool LrcBar::loadLyrics(QString filepath)
{
m_currentTimeMs = 0;
return m_lrcMgr->loadLyrics(filepath);
}
void LrcBar::playbackPositionChanged(int timestampMs, int totalTimeMs) void LrcBar::playbackPositionChanged(int timestampMs, int totalTimeMs)
{ {
if (!isVisible()) return; if (!isVisible()) return;
m_currentTimeMs = timestampMs; m_currentTimeMs = timestampMs;
m_lrcMgr->updateCurrentTimeMs(timestampMs, totalTimeMs);
update(); update();
} }
+1 -2
View File
@@ -13,10 +13,9 @@ class LrcBar : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit LrcBar(QWidget *parent); explicit LrcBar(QWidget *parent, LyricsManager *mgr);
~LrcBar(); ~LrcBar();
bool loadLyrics(QString filepath);
void playbackPositionChanged(int timestampMs, int totalTimeMs); void playbackPositionChanged(int timestampMs, int totalTimeMs);
protected: protected:
+24 -1
View File
@@ -43,10 +43,14 @@ bool LyricsManager::loadLyrics(QString filepath)
if (!filepath.endsWith(".lrc", Qt::CaseInsensitive)) { if (!filepath.endsWith(".lrc", Qt::CaseInsensitive)) {
fileInfo.setFile(fileInfo.dir().filePath(fileInfo.completeBaseName() + ".lrc")); fileInfo.setFile(fileInfo.dir().filePath(fileInfo.completeBaseName() + ".lrc"));
} }
if (!fileInfo.exists()) return false; if (!fileInfo.exists()) {
emit lyricsLoaded(false);
return false;
}
QFile file(fileInfo.absoluteFilePath()); QFile file(fileInfo.absoluteFilePath());
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
emit lyricsLoaded(false);
return false; return false;
} }
QByteArray fileContent(file.readAll()); QByteArray fileContent(file.readAll());
@@ -126,9 +130,11 @@ bool LyricsManager::loadLyrics(QString filepath)
if (!m_lyricsMap.isEmpty()) { if (!m_lyricsMap.isEmpty()) {
m_timestampList = m_lyricsMap.keys(); m_timestampList = m_lyricsMap.keys();
std::sort(m_timestampList.begin(), m_timestampList.end()); std::sort(m_timestampList.begin(), m_timestampList.end());
emit lyricsLoaded(true);
return true; return true;
} }
emit lyricsLoaded(false);
return false; return false;
} }
@@ -151,6 +157,22 @@ void LyricsManager::updateCurrentTimeMs(int curTimeMs, int totalTimeMs)
iter--; iter--;
} }
m_currentLyricsTime = *iter; m_currentLyricsTime = *iter;
m_currentLineIndex = std::distance(m_timestampList.begin(), iter);
}
const QList<int>& LyricsManager::timestamps() const
{
return m_timestampList;
}
const QHash<int, QString>& LyricsManager::lyricsMap() const
{
return m_lyricsMap;
}
int LyricsManager::currentLineIndex() const
{
return m_currentLineIndex;
} }
QString LyricsManager::lyrics(int lineOffset) const QString LyricsManager::lyrics(int lineOffset) const
@@ -197,6 +219,7 @@ void LyricsManager::reset()
{ {
m_currentLyricsTime = 0; m_currentLyricsTime = 0;
m_nextLyricsTime = 0; m_nextLyricsTime = 0;
m_currentLineIndex = -1;
m_timeOffset = 0; m_timeOffset = 0;
m_lyricsMap.clear(); m_lyricsMap.clear();
m_timestampList.clear(); m_timestampList.clear();
+9
View File
@@ -25,8 +25,16 @@ public:
QString lyrics(int lineOffset = 0) const; QString lyrics(int lineOffset = 0) const;
double maskPercent(int curTimeMs); double maskPercent(int curTimeMs);
const QList<int>& timestamps() const;
const QHash<int, QString>& lyricsMap() const;
int currentLineIndex() const;
static int parseTimeToMilliseconds(const QString& timeString); static int parseTimeToMilliseconds(const QString& timeString);
signals:
void lyricsLoaded(bool success);
protected: protected:
@@ -39,5 +47,6 @@ private:
QList<int> m_timestampList; QList<int> m_timestampList;
int m_currentLyricsTime = 0; int m_currentLyricsTime = 0;
int m_nextLyricsTime = 0; int m_nextLyricsTime = 0;
int m_currentLineIndex = -1;
int m_timeOffset = 0; int m_timeOffset = 0;
}; };
+131
View File
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2026 Gary Wang <opensource@blumia.net>
//
// SPDX-License-Identifier: MIT
#include "lyricswidget.h"
#include "lyricsmanager.h"
#include <QListWidget>
#include <QVBoxLayout>
#include <QListWidgetItem>
#include <QListWidgetItem>
#include <QFont>
#include <QPropertyAnimation>
#include <QScrollBar>
#include <QEasingCurve>
LyricsWidget::LyricsWidget(QWidget *parent)
: QWidget(parent)
, m_listWidget(new QListWidget(this))
{
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_listWidget);
m_listWidget->setFrameShape(QFrame::NoFrame);
m_listWidget->setStyleSheet("background: transparent;");
m_listWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_listWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Hide scrollbar for cleaner look? Or Keep it? User didn't specify. Hiding it looks more like "Lyrics Mode"
// Enable word wrap
m_listWidget->setWordWrap(true);
m_listWidget->setTextElideMode(Qt::ElideNone);
m_listWidget->setSelectionMode(QAbstractItemView::NoSelection);
m_listWidget->setFocusPolicy(Qt::NoFocus);
}
void LyricsWidget::setLyricsManager(LyricsManager *mgr)
{
if (m_lyricsManager) {
disconnect(m_lyricsManager, nullptr, this, nullptr);
}
m_lyricsManager = mgr;
if (m_lyricsManager) {
connect(m_lyricsManager, &LyricsManager::lyricsLoaded, this, &LyricsWidget::onLyricsLoaded);
// Load initial state if any
onLyricsLoaded(m_lyricsManager->hasLyrics());
}
}
void LyricsWidget::updatePosition(qint64 position)
{
if (!m_lyricsManager || !m_lyricsManager->hasLyrics() || !isVisible()) return;
// Note: rely on LyricsManager::updateCurrentTimeMs()
int index = m_lyricsManager->currentLineIndex();
if (index != m_lastHighlightIndex) {
highlightCurrentLine(index);
}
}
void LyricsWidget::onLyricsLoaded(bool success)
{
m_listWidget->clear();
m_lastHighlightIndex = -1;
if (!success || !m_lyricsManager) return;
const QList<int>& timestamps = m_lyricsManager->timestamps();
const QHash<int, QString>& lyricsMap = m_lyricsManager->lyricsMap();
for (int timestamp : timestamps) {
QListWidgetItem *item = new QListWidgetItem(lyricsMap.value(timestamp));
item->setTextAlignment(Qt::AlignCenter);
// Default style
QFont font = item->font();
font.setPointSize(10);
item->setFont(font);
item->setForeground(QColor(255, 255, 255, 150)); // Dimmed
m_listWidget->addItem(item);
}
}
void LyricsWidget::highlightCurrentLine(int index)
{
if (index < 0 || index >= m_listWidget->count()) return;
// Reset old highlight
if (m_lastHighlightIndex >= 0 && m_lastHighlightIndex < m_listWidget->count()) {
QListWidgetItem *oldItem = m_listWidget->item(m_lastHighlightIndex);
QFont font = oldItem->font();
font.setPointSize(10);
font.setBold(false);
oldItem->setFont(font);
oldItem->setForeground(QColor(255, 255, 255, 150));
}
// Set new highlight
QListWidgetItem *newItem = m_listWidget->item(index);
QFont font = newItem->font();
font.setPointSize(14);
font.setBold(true);
newItem->setFont(font);
newItem->setForeground(QColor(255, 255, 255, 255));
// Smooth scroll
QScrollBar *vBar = m_listWidget->verticalScrollBar();
int startScroll = vBar->value();
m_listWidget->scrollToItem(newItem, QAbstractItemView::PositionAtCenter);
int endScroll = vBar->value();
// Restore and animate
if (startScroll != endScroll) {
vBar->setValue(startScroll);
if (!m_scrollAnimation) {
m_scrollAnimation = new QPropertyAnimation(vBar, "value", this);
m_scrollAnimation->setDuration(400);
m_scrollAnimation->setEasingCurve(QEasingCurve::OutCubic);
}
m_scrollAnimation->stop();
m_scrollAnimation->setStartValue(startScroll);
m_scrollAnimation->setEndValue(endScroll);
m_scrollAnimation->start();
}
m_lastHighlightIndex = index;
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Gary Wang <opensource@blumia.net>
//
// SPDX-License-Identifier: MIT
#pragma once
#include <QWidget>
QT_BEGIN_NAMESPACE
class QListWidget;
class QListWidgetItem;
class QPropertyAnimation;
QT_END_NAMESPACE
class LyricsManager;
class LyricsWidget : public QWidget
{
Q_OBJECT
public:
explicit LyricsWidget(QWidget *parent = nullptr);
void setLyricsManager(LyricsManager *mgr);
void updatePosition(qint64 position);
private slots:
void onLyricsLoaded(bool success);
private:
QListWidget *m_listWidget;
LyricsManager *m_lyricsManager = nullptr;
int m_lastHighlightIndex = -1;
QPropertyAnimation *m_scrollAnimation = nullptr;
void updateLyrics();
void highlightCurrentLine(int index);
};
+17
View File
@@ -13,8 +13,25 @@
#include <QTranslator> #include <QTranslator>
#include <QDir> #include <QDir>
#ifdef Q_OS_WIN
#include <windows.h>
#endif
int main(int argc, char *argv[]) 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<SetCurrentProcessExplicitAppUserModelIDProc>(
GetProcAddress(shell32, "SetCurrentProcessExplicitAppUserModelID"));
if (proc) {
proc(L"net.blumia.pineapple-music");
}
FreeLibrary(shell32);
}
#endif
QApplication a(argc, argv); QApplication a(argc, argv);
QTranslator translator; QTranslator translator;
+82 -11
View File
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 Gary Wang <opensource@blumia.net> // SPDX-FileCopyrightText: 2026 Gary Wang <opensource@blumia.net>
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@@ -9,6 +9,9 @@
#include "fftspectrum.h" #include "fftspectrum.h"
#include "lrcbar.h" #include "lrcbar.h"
#include "taskbarmanager.h" #include "taskbarmanager.h"
#include "winmediacontrol.h"
#include "lyricsmanager.h"
#include "lyricswidget.h"
// taglib // taglib
#ifndef NO_TAGLIB #ifndef NO_TAGLIB
@@ -37,6 +40,7 @@
#include <QSettings> #include <QSettings>
#include <QGraphicsDropShadowEffect> #include <QGraphicsDropShadowEffect>
#include <QTimer> #include <QTimer>
#include <QFileInfo>
constexpr QSize miniSize(490, 160); constexpr QSize miniSize(490, 160);
constexpr QSize fullSize(490, 420); constexpr QSize fullSize(490, 420);
@@ -48,11 +52,17 @@ MainWindow::MainWindow(QWidget *parent)
, m_mediaPlayer(new QMediaPlayer(this)) , m_mediaPlayer(new QMediaPlayer(this))
, m_audioOutput(new QAudioOutput(this)) , m_audioOutput(new QAudioOutput(this))
, m_fftSpectrum(new FFTSpectrum(this)) , m_fftSpectrum(new FFTSpectrum(this))
, m_lrcbar(new LrcBar(nullptr)) , m_lyricsManager(new LyricsManager(this))
, m_lrcbar(new LrcBar(nullptr, m_lyricsManager))
, m_lyricsWidget(new LyricsWidget(this))
, m_playlistManager(new PlaylistManager(this)) , m_playlistManager(new PlaylistManager(this))
, m_taskbarManager(new TaskBarManager(this)) , m_taskbarManager(new TaskBarManager(this))
, m_mediaControl(new WinMediaControl(this))
{ {
ui->setupUi(this); ui->setupUi(this);
m_lyricsWidget->setLyricsManager(m_lyricsManager);
ui->pluginStackedWidget->addWidget(m_lyricsWidget);
m_playlistManager->setAutoLoadFilterSuffixes({ m_playlistManager->setAutoLoadFilterSuffixes({
"*.mp3", "*.wav", "*.aiff", "*.ape", "*.flac", "*.ogg", "*.oga", "*.mpga", "*.aac", "*.tta" "*.mp3", "*.wav", "*.aiff", "*.ape", "*.flac", "*.ogg", "*.oga", "*.mpga", "*.aac", "*.tta"
}); });
@@ -91,7 +101,9 @@ MainWindow::MainWindow(QWidget *parent)
centerWindow(); centerWindow();
QTimer::singleShot(1000, [this](){ QTimer::singleShot(1000, [this](){
m_taskbarManager->setWinId(window()->winId()); auto winId = window()->winId();
m_taskbarManager->setWinId(winId);
m_mediaControl->initialize(reinterpret_cast<void*>(winId));
}); });
} }
@@ -156,15 +168,23 @@ void MainWindow::setAudioMetadataForDisplay(QString title, QString artist, QStri
{ {
Q_UNUSED(album); Q_UNUSED(album);
if (title.isEmpty()) {
title = QFileInfo(m_mediaPlayer->source().fileName()).completeBaseName();
}
if (!title.isEmpty()) { if (!title.isEmpty()) {
if (!artist.isEmpty()) { if (!artist.isEmpty()) {
ui->titleLabel->setText(QString("%1 - %2").arg(artist, title)); title = QString("%1 - %2").arg(artist, title);
} else if (!album.isEmpty()) { } else if (!album.isEmpty()) {
ui->titleLabel->setText(QString("%1 - %2").arg(album, title)); title = QString("%1 - %2").arg(album, title);
} else { } 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) void MainWindow::localSocketPlayAudioFiles(QVariant audioFilesVariant)
@@ -255,7 +275,7 @@ void MainWindow::dropEvent(QDropEvent *e)
} }
if (fileName.endsWith(".lrc")) { if (fileName.endsWith(".lrc")) {
m_lrcbar->loadLyrics(fileName); m_lyricsManager->loadLyrics(fileName);
return; return;
} }
@@ -313,7 +333,7 @@ void MainWindow::loadFile(const QUrl &url)
{ {
const QString filePath = url.toLocalFile(); const QString filePath = url.toLocalFile();
m_mediaPlayer->setSource(url); m_mediaPlayer->setSource(url);
m_lrcbar->loadLyrics(filePath); m_lyricsManager->loadLyrics(filePath);
QList<std::pair<qint64, QString>> chapters(PlaybackProgressIndicator::tryLoadChapters(filePath)); QList<std::pair<qint64, QString>> chapters(PlaybackProgressIndicator::tryLoadChapters(filePath));
ui->playbackProgressIndicator->setChapters(chapters); ui->playbackProgressIndicator->setChapters(chapters);
} }
@@ -503,10 +523,14 @@ void MainWindow::initConnections()
} }
QVariant coverArt(metadata.value(QMediaMetaData::ThumbnailImage)); QVariant coverArt(metadata.value(QMediaMetaData::ThumbnailImage));
if (!coverArt.isNull()) { if (!coverArt.isNull()) {
ui->coverLabel->setPixmap(QPixmap::fromImage(coverArt.value<QImage>())); QImage coverImage = coverArt.value<QImage>();
ui->coverLabel->setPixmap(QPixmap::fromImage(coverImage));
m_mediaControl->setCoverArt(coverImage);
} else { } else {
qDebug() << "No ThumbnailImage!" << metadata.keys(); qDebug() << "No ThumbnailImage!" << metadata.keys();
ui->coverLabel->setPixmap(QPixmap(":/icons/icons/media-album-cover.svg")); 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){ connect(m_playlistManager, &PlaylistManager::currentIndexChanged, this, [=](int index){
@@ -518,8 +542,14 @@ void MainWindow::initConnections()
if (m_mediaPlayer->duration() != 0) { if (m_mediaPlayer->duration() != 0) {
ui->playbackProgressIndicator->setPosition(pos); ui->playbackProgressIndicator->setPosition(pos);
m_taskbarManager->setProgressValue(pos); m_taskbarManager->setProgressValue(pos);
m_mediaControl->setPosition(pos, m_mediaPlayer->duration());
} }
if (m_lrcbar->isVisible() || m_lyricsWidget->isVisible()) {
m_lyricsManager->updateCurrentTimeMs(pos, m_mediaPlayer->duration());
m_lrcbar->playbackPositionChanged(pos, m_mediaPlayer->duration()); m_lrcbar->playbackPositionChanged(pos, m_mediaPlayer->duration());
m_lyricsWidget->updatePosition(pos);
}
static QString lastChapterName; static QString lastChapterName;
if (ui->playbackProgressIndicator->chapterModel()->rowCount() > 0) { if (ui->playbackProgressIndicator->chapterModel()->rowCount() > 0) {
@@ -556,10 +586,15 @@ void MainWindow::initConnections()
switch (newState) { switch (newState) {
case QMediaPlayer::PlayingState: case QMediaPlayer::PlayingState:
ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-pause.png")); ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-pause.png"));
m_mediaControl->setPlaybackState(1);
break; break;
case QMediaPlayer::StoppedState: case QMediaPlayer::StoppedState:
ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-start.png"));
m_mediaControl->setPlaybackState(0);
break;
case QMediaPlayer::PausedState: case QMediaPlayer::PausedState:
ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-start.png")); ui->playBtn->setIcon(QIcon(":/icons/icons/media-playback-start.png"));
m_mediaControl->setPlaybackState(2);
break; break;
} }
m_taskbarManager->setPlaybackState(newState); m_taskbarManager->setPlaybackState(newState);
@@ -615,6 +650,33 @@ void MainWindow::initConnections()
on_nextBtn_clicked(); on_nextBtn_clicked();
}); });
connect(m_mediaControl, &WinMediaControl::playRequested, this, [this](){
if (QList<QMediaPlayer::PlaybackState> {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) { connect(m_mediaPlayer, &QMediaPlayer::errorOccurred, this, [=](QMediaPlayer::Error error, const QString &errorString) {
qDebug() << error << errorString; qDebug() << error << errorString;
}); });
@@ -718,6 +780,15 @@ void MainWindow::on_playlistView_activated(const QModelIndex &index)
void MainWindow::on_lrcBtn_clicked() void MainWindow::on_lrcBtn_clicked()
{ {
if (size().height() < 200) {
setFixedSize(fullSize);
}
if (ui->pluginStackedWidget->currentWidget() != m_lyricsWidget) {
ui->pluginStackedWidget->setCurrentWidget(m_lyricsWidget);
return;
}
if (m_lrcbar->isVisible()) { if (m_lrcbar->isVisible()) {
m_lrcbar->hide(); m_lrcbar->hide();
} else { } else {
@@ -764,7 +835,7 @@ void MainWindow::on_actionHelp_triggered()
infoBox.setWindowTitle(tr("About")); infoBox.setWindowTitle(tr("About"));
infoBox.setStandardButtons(QMessageBox::Ok); infoBox.setStandardButtons(QMessageBox::Ok);
infoBox.setText( infoBox.setText(
tr("Pineapple Music") % QString("%1 %2").arg(tr("Pineapple Music")).arg(PMUSIC_VERSION_STRING) %
"\n\n" % "\n\n" %
tr("Based on the following free software libraries:") % tr("Based on the following free software libraries:") %
"\n\n" % "\n\n" %
@@ -785,7 +856,7 @@ void MainWindow::on_actionHelp_triggered()
"\n" "\n"
"[Source Code](https://github.com/BLumia/pineapple-music)\n" "[Source Code](https://github.com/BLumia/pineapple-music)\n"
"\n" "\n"
"Copyright &copy; 2025 [BLumia](https://github.com/BLumia/)" "Copyright &copy; 2026 [BLumia](https://github.com/BLumia/)"
); );
infoBox.setTextFormat(Qt::MarkdownText); infoBox.setTextFormat(Qt::MarkdownText);
infoBox.exec(); infoBox.exec();
+6
View File
@@ -21,8 +21,11 @@ QT_END_NAMESPACE
class FFTSpectrum; class FFTSpectrum;
class LrcBar; class LrcBar;
class LyricsManager;
class LyricsWidget;
class PlaylistManager; class PlaylistManager;
class TaskBarManager; class TaskBarManager;
class WinMediaControl;
class MainWindow : public QMainWindow class MainWindow : public QMainWindow
{ {
Q_OBJECT Q_OBJECT
@@ -104,10 +107,13 @@ private:
QMediaPlayer *m_mediaPlayer; QMediaPlayer *m_mediaPlayer;
QAudioOutput *m_audioOutput; QAudioOutput *m_audioOutput;
FFTSpectrum* m_fftSpectrum; FFTSpectrum* m_fftSpectrum;
LyricsManager *m_lyricsManager;
LrcBar *m_lrcbar; LrcBar *m_lrcbar;
LyricsWidget *m_lyricsWidget;
QPropertyAnimation *m_fadeOutAnimation; QPropertyAnimation *m_fadeOutAnimation;
PlaylistManager *m_playlistManager; PlaylistManager *m_playlistManager;
TaskBarManager *m_taskbarManager; TaskBarManager *m_taskbarManager;
WinMediaControl *m_mediaControl;
void initUiAndAnimation(); void initUiAndAnimation();
void initConnections(); void initConnections();
+421
View File
@@ -0,0 +1,421 @@
// SPDX-FileCopyrightText: 2026 Gary Wang <opensource@blumia.net>
//
// SPDX-License-Identifier: MIT
#include "winmediacontrol.h"
#include <QDebug>
#include <QBuffer>
#ifdef Q_OS_WIN
#include <roapi.h>
#include <windows.media.h>
#include <windows.storage.streams.h>
#include <systemmediatransportcontrolsinterop.h>
#include <wrl.h>
#include <wrl/wrappers/corewrappers.h>
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<SystemMediaTransportControls*,
SystemMediaTransportControlsButtonPressedEventArgs*>;
using PositionChangeHandler = ITypedEventHandler<SystemMediaTransportControls*,
PlaybackPositionChangeRequestedEventArgs*>;
// ---------------------------------------------------------------------------
// 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<ButtonPressedHandler*>(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<PositionChangeHandler*>(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<ISystemMediaTransportControls> smtc;
ComPtr<ISystemMediaTransportControls2> smtc2;
ComPtr<ISystemMediaTransportControlsDisplayUpdater> updater;
ComPtr<IMusicDisplayProperties> 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>(hwnd);
ComPtr<ISystemMediaTransportControlsInterop> 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<IRandomAccessStreamReference> imageToStreamReference(const QImage& image)
{
QByteArray imageData;
QBuffer buffer(&imageData);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
buffer.close();
ComPtr<IInspectable> insp;
HRESULT hr = RoActivateInstance(
HStringReference(RuntimeClass_Windows_Storage_Streams_InMemoryRandomAccessStream).Get(),
&insp);
if (FAILED(hr) || !insp) return nullptr;
ComPtr<IRandomAccessStream> stream;
insp.As(&stream);
if (!stream) return nullptr;
ComPtr<IOutputStream> outputStream;
stream->GetOutputStreamAt(0, &outputStream);
ComPtr<IDataWriterFactory> writerFactory;
RoGetActivationFactory(
HStringReference(RuntimeClass_Windows_Storage_Streams_DataWriter).Get(),
IID_PPV_ARGS(&writerFactory));
if (!writerFactory) return nullptr;
ComPtr<IDataWriter> writer;
writerFactory->CreateDataWriter(outputStream.Get(), &writer);
if (!writer) return nullptr;
writer->WriteBytes(
static_cast<UINT32>(imageData.size()),
reinterpret_cast<BYTE*>(imageData.data()));
ComPtr<IAsyncOperation<UINT32>> 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<IRandomAccessStreamReferenceStatics> rars;
RoGetActivationFactory(
HStringReference(RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference).Get(),
IID_PPV_ARGS(&rars));
if (!rars) return nullptr;
ComPtr<IRandomAccessStreamReference> 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<IMusicDisplayProperties2> 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<IInspectable> insp;
HRESULT hr = RoActivateInstance(
HStringReference(RuntimeClass_Windows_Media_SystemMediaTransportControlsTimelineProperties).Get(),
&insp);
if (FAILED(hr) || !insp) return;
ComPtr<ISystemMediaTransportControlsTimelineProperties> 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
+42
View File
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2026 Gary Wang <opensource@blumia.net>
//
// SPDX-License-Identifier: MIT
#pragma once
#include <QObject>
#include <QString>
#include <QImage>
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;
};