From 2a92f4ea7f5ceb9cebefd7536b68fe6575755fa8 Mon Sep 17 00:00:00 2001 From: Gary Wang Date: Fri, 20 Sep 2024 20:59:40 +0800 Subject: [PATCH] feat: basic lyrics support --- CMakeLists.txt | 4 + README.md | 1 + languages/pineapple-music.ts | 66 +++++++------ languages/pineapple-music_zh_CN.ts | 58 +++++++----- lrcbar.cpp | 128 ++++++++++++++++++++++++++ lrcbar.h | 36 ++++++++ lyricsmanager.cpp | 143 +++++++++++++++++++++++++++++ lyricsmanager.h | 37 ++++++++ mainwindow.cpp | 25 ++++- mainwindow.h | 3 + mainwindow.ui | 40 ++++++-- 11 files changed, 484 insertions(+), 57 deletions(-) create mode 100644 lrcbar.cpp create mode 100644 lrcbar.h create mode 100644 lyricsmanager.cpp create mode 100644 lyricsmanager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e14e008..ad5f83e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,8 @@ set (PMUSIC_CPP_FILES seekableslider.cpp playlistmanager.cpp singleapplicationmanager.cpp + lrcbar.cpp + lyricsmanager.cpp ) set (PMUSIC_HEADER_FILES @@ -33,6 +35,8 @@ set (PMUSIC_HEADER_FILES seekableslider.h playlistmanager.h singleapplicationmanager.h + lrcbar.h + lyricsmanager.h ) set (PMUSIC_UI_FILES diff --git a/README.md b/README.md index 228d007..66fbc6d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Since **I** just need a simple player which *just works* right now, so I did man - File format support will be limited by the [FFmpeg version that Qt 6 uses](https://doc.qt.io/qt-6/qtmultimedia-attribution-ffmpeg.html). - ...which if you use Qt's official binary, only contains the LGPLv2.1+ part. (already good enough, tho) - 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. ## Build diff --git a/languages/pineapple-music.ts b/languages/pineapple-music.ts index f97bfe1..422f3a4 100644 --- a/languages/pineapple-music.ts +++ b/languages/pineapple-music.ts @@ -1,71 +1,85 @@ + + LrcBar + + + (Interlude...) + + + MainWindow - + Mono - + Stereo - + %1 Channels - + Select songs to play - + Audio Files + + + Select image as background skin + + + + + Image files (*.jpg *.jpeg *.png *.gif) + + - Pineapple Player - - - - - ^ - - - - - No song loaded... - - - - - Drag and drop file to load + + Pineapple Music - - 0:00 + No song loaded... - + + Drag and drop file to load + + + + + Lrc + Lyrics + + + + Sample Rate: %1 Hz - + Bitrate: %1 Kbps - + Channel Count: %1 diff --git a/languages/pineapple-music_zh_CN.ts b/languages/pineapple-music_zh_CN.ts index 25449d3..c7f8ef0 100644 --- a/languages/pineapple-music_zh_CN.ts +++ b/languages/pineapple-music_zh_CN.ts @@ -1,71 +1,85 @@ + + LrcBar + + + (Interlude...) + (间奏…) + + MainWindow - + Mono 单声道 - + Stereo 立体声 - + %1 Channels %1 声道 - + Select songs to play 选择要播放的曲目 - + Audio Files 音频文件 + + + Select image as background skin + 选择图片作为背景皮肤 + + + + Image files (*.jpg *.jpeg *.png *.gif) + 图片文件 (*.jpg *.jpeg *.png *.gif) + - Pineapple Player - + + Pineapple Music + 菠萝音乐 - - ^ - - - - + No song loaded... 未加载曲目... - + Drag and drop file to load 拖放文件来播放 - - - 0:00 - + + Lrc + Lyrics + 歌词 - + Sample Rate: %1 Hz 采样率: %1 Hz - + Bitrate: %1 Kbps 比特率: %1 Kbps - + Channel Count: %1 声道数: %1 @@ -75,7 +89,7 @@ File list. - 文件列表 + 文件列表。 diff --git a/lrcbar.cpp b/lrcbar.cpp new file mode 100644 index 0000000..b6c4866 --- /dev/null +++ b/lrcbar.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2024 Gary Wang +// +// SPDX-License-Identifier: MIT + +#include "lrcbar.h" + +#include "lyricsmanager.h" + +#include +#include +#include + +LrcBar::LrcBar(QWidget *parent) + : QWidget(parent, Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool) + , m_lrcMgr(new LyricsManager(this)) +{ + m_font.setPointSize(30); + m_font.setStyleStrategy(QFont::PreferAntialias); + + QSize windowSize(sizeHint()); + + QFontMetrics fm(m_font); + m_baseLinearGradient.setColorAt(0, QColor(20, 100, 200)); + m_baseLinearGradient.setColorAt(1, QColor(0, 80, 255)); + m_baseLinearGradient.setStart(0, (windowSize.height() - fm.height()) / 2); + m_baseLinearGradient.setFinalStop(0, (windowSize.height() + fm.height()) / 2); + m_maskLinearGradient.setColorAt(0, QColor(255, 128, 0)); + m_maskLinearGradient.setColorAt(0.5, QColor(255, 255, 0)); + m_maskLinearGradient.setColorAt(1, QColor(255, 128, 0)); + m_maskLinearGradient.setStart(0, (windowSize.height() - fm.height()) / 2); + m_maskLinearGradient.setFinalStop(0, (windowSize.height() + fm.height()) / 2); + + setAttribute(Qt::WA_TranslucentBackground); + setMouseTracking(true); + setGeometry(QRect(QPoint((qApp->primaryScreen()->geometry().width() - windowSize.width()) / 2, + qApp->primaryScreen()->geometry().height() - windowSize.height() - 50), + windowSize)); +} + +LrcBar::~LrcBar() +{ + +} + +bool LrcBar::loadLyrics(QString filepath) +{ + m_currentTimeMs = 0; + return m_lrcMgr->loadLyrics(filepath); +} + +void LrcBar::playbackPositionChanged(int timestampMs, int totalTimeMs) +{ + if (!isVisible()) return; + + m_currentTimeMs = timestampMs; + m_lrcMgr->updateCurrentTimeMs(timestampMs, totalTimeMs); + update(); +} + +QSize LrcBar::sizeHint() const +{ + return QSize(1000, 88); +} + +void LrcBar::mouseMoveEvent(QMouseEvent *event) +{ + if (event->buttons() & Qt::LeftButton) { + window()->windowHandle()->startSystemMove(); + event->accept(); + } + + return QWidget::mouseMoveEvent(event); +} + +void LrcBar::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + if (underMouse()) { + painter.setBrush(QBrush(QColor(255, 255, 255, 120))); + painter.setPen(Qt::NoPen); + painter.drawRect(0, 0, width(), height()); + } + painter.setFont(m_font); + painter.setRenderHint(QPainter::Antialiasing, true); + + QString curLrc(m_lrcMgr->lyrics().trimmed()); + if (curLrc.isEmpty()) { + curLrc = m_lrcMgr->hasLyrics() ? tr("(Interlude...)") + : QCoreApplication::translate("MainWindow", "Pineapple Music", nullptr); + } + + QFontMetrics fm(m_font); + int lrcWidth = fm.horizontalAdvance(curLrc); + int maskWidth = lrcWidth * m_lrcMgr->maskPercent(m_currentTimeMs); + int startOffsetX = 0; + + if (fm.horizontalAdvance(curLrc) < width()) { + startOffsetX = (width() - lrcWidth) / 2; + } else { + if (maskWidth < width() / 2) { + startOffsetX = 0; + } else if (lrcWidth - maskWidth < width() / 2) { + startOffsetX = width() - lrcWidth; + } else { + startOffsetX = 0 - (maskWidth - width() / 2); + } + } + + // shadow + painter.setPen(QColor(0, 0, 0, 80)); + painter.drawText(startOffsetX + 2, 2, lrcWidth, this->height(), Qt::AlignVCenter, curLrc); + // text itself + painter.setPen(QPen(m_baseLinearGradient, 0)); + painter.drawText(startOffsetX, 0, lrcWidth, this->height(), Qt::AlignVCenter, curLrc); + // mask + painter.setPen(QPen(m_maskLinearGradient, 0)); + painter.drawText(startOffsetX, 0, maskWidth, this->height(), Qt::AlignVCenter, curLrc); +} + +void LrcBar::enterEvent(QEnterEvent *) +{ + update(); +} + +void LrcBar::leaveEvent(QEvent *) +{ + update(); +} diff --git a/lrcbar.h b/lrcbar.h new file mode 100644 index 0000000..0c29f8a --- /dev/null +++ b/lrcbar.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2024 Gary Wang +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +class LyricsManager; +class LrcBar : public QWidget +{ + Q_OBJECT +public: + explicit LrcBar(QWidget *parent); + ~LrcBar(); + + bool loadLyrics(QString filepath); + void playbackPositionChanged(int timestampMs, int totalTimeMs); + +protected: + QSize sizeHint() const override; + void mouseMoveEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *) override; + void enterEvent(QEnterEvent *) override; + void leaveEvent(QEvent *) override; + +private: + int m_currentTimeMs = 0; + LyricsManager * m_lrcMgr; + QFont m_font; + QLinearGradient m_baseLinearGradient; + QLinearGradient m_maskLinearGradient; + bool m_underMouse = false; +}; diff --git a/lyricsmanager.cpp b/lyricsmanager.cpp new file mode 100644 index 0000000..eb508d3 --- /dev/null +++ b/lyricsmanager.cpp @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2024 Gary Wang +// +// SPDX-License-Identifier: MIT + +#include "lyricsmanager.h" + +#include +#include +#include + +LyricsManager::LyricsManager(QObject *parent) + : QObject(parent) +{ + +} + +LyricsManager::~LyricsManager() +{ + +} + +bool LyricsManager::loadLyrics(QString filepath) +{ + // reset state + reset(); + + // check and load file + QFileInfo fileInfo(filepath); + if (!filepath.endsWith(".lrc", Qt::CaseInsensitive)) { + fileInfo.setFile(fileInfo.dir().filePath(fileInfo.completeBaseName() + ".lrc")); + } + if (!fileInfo.exists()) return false; + + QFile file(fileInfo.absoluteFilePath()); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + QTextStream stream(&file); + QStringList lines = QString(stream.readAll()).split('\n'); + file.close(); + + // parse lyrics timestamp + QRegularExpression tagRegex(R"regex(\[(ti|ar|al|au|length|by|offset|tool|re|ve|#):\s?([^\]]*)\]$)regex"); + QRegularExpression lrcRegex(R"regex(\[(\d{2,3}:\d{2}\.\d{2})\](.*))regex"); + bool tagSectionPassed = false; + + for (QString line : std::as_const(lines)) { + line = line.trimmed(); + if (line.isEmpty()) continue; + + if (!tagSectionPassed) { + QRegularExpressionMatch tagMatch = tagRegex.match(line); + if (tagMatch.hasMatch()) { + QString tag(tagMatch.captured(1)); + if (tag == QLatin1String("offset")) { + // The value is prefixed with either + or -, with + causing lyrics to appear sooner + m_timeOffset = -tagMatch.captured(2).toInt(); + qDebug() << m_timeOffset; + } + qDebug() << "[tag]" << tagMatch.captured(1) << tagMatch.captured(2); + continue; + } + } + + QRegularExpressionMatch match = lrcRegex.match(line); + if (match.hasMatch()) { + tagSectionPassed = true; + QTime timestamp(QTime::fromString(match.captured(1), "m:s.zz")); + m_lyricsMap.insert(timestamp.msecsSinceStartOfDay(), match.captured(2)); + qDebug() << "[lrc]" << match.captured(1) << match.captured(2); + } + } + if (!m_lyricsMap.isEmpty()) { + m_timestampList = m_lyricsMap.keys(); + std::sort(m_timestampList.begin(), m_timestampList.end()); + return true; + } + + return false; +} + +bool LyricsManager::hasLyrics() const +{ + return !m_lyricsMap.isEmpty(); +} + +void LyricsManager::updateCurrentTimeMs(int curTimeMs, int totalTimeMs) +{ + if (!hasLyrics()) return; + + // TODO: we don't need to find from the top everytime the time is updated + auto iter = std::find_if(m_timestampList.begin(), m_timestampList.end() + 1, [&curTimeMs, this](int timestamp) -> bool { + return (timestamp + m_timeOffset) > curTimeMs; + }); + + m_nextLyricsTime = (iter == m_timestampList.end() + 1) ? totalTimeMs : *iter; + if (iter != m_timestampList.begin() && iter != (m_timestampList.end() + 1)) { + iter--; + } + m_currentLyricsTime = *iter; +} + +QString LyricsManager::lyrics(int lineOffset) const +{ + if (!hasLyrics()) return QString(); + + int index = m_timestampList.indexOf(m_currentLyricsTime) + lineOffset; + if (index >= 0 && index < m_timestampList.count()) { + int timestamp = m_timestampList.at(index); + return m_lyricsMap.value(timestamp); + } else { + return QString(); + } +} + +double LyricsManager::maskPercent(int curTimeMs) +{ + if (!hasLyrics()) return 0; + if (curTimeMs <= currentLyricsTime()) return 0; + if (curTimeMs >= nextLyricsTime()) return 1; + if (m_nextLyricsTime == currentLyricsTime()) return 1; + + return (double)(curTimeMs - currentLyricsTime()) / (m_nextLyricsTime - m_currentLyricsTime); +} + +void LyricsManager::reset() +{ + m_currentLyricsTime = 0; + m_nextLyricsTime = 0; + m_timeOffset = 0; + m_lyricsMap.clear(); + m_timestampList.clear(); +} + +int LyricsManager::currentLyricsTime() const +{ + return m_currentLyricsTime + m_timeOffset; +} + +int LyricsManager::nextLyricsTime() const +{ + return m_nextLyricsTime + m_timeOffset; +} diff --git a/lyricsmanager.h b/lyricsmanager.h new file mode 100644 index 0000000..107cf8b --- /dev/null +++ b/lyricsmanager.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Gary Wang +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +class LyricsManager : public QObject +{ + Q_OBJECT +public: + explicit LyricsManager(QObject *parent); + ~LyricsManager(); + + bool loadLyrics(QString filepath); + bool hasLyrics() const; + void updateCurrentTimeMs(int curTimeMs, int totalTimeMs); + QString lyrics(int lineOffset = 0) const; + double maskPercent(int curTimeMs); + +protected: + + +private: + void reset(); + int currentLyricsTime() const; + int nextLyricsTime() const; + + QHash m_lyricsMap; + QList m_timestampList; + int m_currentLyricsTime = 0; + int m_nextLyricsTime = 0; + int m_timeOffset = 0; +}; diff --git a/mainwindow.cpp b/mainwindow.cpp index a8cdb42..942ee5b 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -6,6 +6,7 @@ #include "./ui_mainwindow.h" #include "playlistmanager.h" +#include "lrcbar.h" // taglib #ifndef NO_TAGLIB @@ -38,6 +39,7 @@ MainWindow::MainWindow(QWidget *parent) , m_mediaDevices(new QMediaDevices(this)) , m_mediaPlayer(new QMediaPlayer(this)) , m_audioOutput(new QAudioOutput(this)) + , m_lrcbar(new LrcBar(nullptr)) , m_playlistManager(new PlaylistManager(this)) { ui->setupUi(this); @@ -48,8 +50,8 @@ MainWindow::MainWindow(QWidget *parent) m_mediaPlayer->setLoops(QMediaPlayer::Infinite); ui->playlistView->setModel(m_playlistManager->model()); - this->setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinimizeButtonHint); - this->setAttribute(Qt::WA_TranslucentBackground, true); + setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinimizeButtonHint); + setAttribute(Qt::WA_TranslucentBackground, true); loadSkinData(); initConnections(); @@ -60,6 +62,7 @@ MainWindow::MainWindow(QWidget *parent) MainWindow::~MainWindow() { + delete m_lrcbar; delete ui; } @@ -215,6 +218,11 @@ void MainWindow::dropEvent(QDropEvent *e) return; } + if (fileName.endsWith(".lrc")) { + m_lrcbar->loadLyrics(fileName); + return; + } + const QModelIndex & modelIndex = m_playlistManager->loadPlaylist(urls); if (modelIndex.isValid()) { loadByModelIndex(modelIndex); @@ -238,11 +246,13 @@ void MainWindow::loadFile() m_playlistManager->loadPlaylist(urlList); m_mediaPlayer->setSource(urlList.first()); + m_lrcbar->loadLyrics(urlList.first().toLocalFile()); } void MainWindow::loadByModelIndex(const QModelIndex & index) { m_mediaPlayer->setSource(m_playlistManager->urlByIndex(index)); + m_lrcbar->loadLyrics(m_playlistManager->localFileByIndex(index)); } void MainWindow::play() @@ -441,6 +451,7 @@ void MainWindow::initConnections() if (m_mediaPlayer->duration() != 0) { ui->playbackSlider->setSliderPosition(ui->playbackSlider->maximum() * pos / m_mediaPlayer->duration()); } + m_lrcbar->playbackPositionChanged(pos, m_mediaPlayer->duration()); }); connect(m_audioOutput, &QAudioOutput::mutedChanged, this, [=](bool muted) { @@ -578,3 +589,13 @@ void MainWindow::on_playlistView_activated(const QModelIndex &index) loadByModelIndex(index); play(); } + +void MainWindow::on_lrcBtn_clicked() +{ + if (m_lrcbar->isVisible()) { + m_lrcbar->hide(); + } else { + m_lrcbar->show(); + } +} + diff --git a/mainwindow.h b/mainwindow.h index 19737ec..b2f3804 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -17,6 +17,7 @@ class QAudioOutput; class QPropertyAnimation; QT_END_NAMESPACE +class LrcBar; class PlaylistManager; class MainWindow : public QMainWindow { @@ -73,6 +74,7 @@ private slots: void on_setSkinBtn_clicked(); void on_playListBtn_clicked(); void on_playlistView_activated(const QModelIndex &index); + void on_lrcBtn_clicked(); signals: void playbackModeChanged(enum PlaybackMode mode); @@ -89,6 +91,7 @@ private: QMediaDevices *m_mediaDevices; QMediaPlayer *m_mediaPlayer; QAudioOutput *m_audioOutput; + LrcBar *m_lrcbar; QPropertyAnimation *m_fadeOutAnimation; PlaylistManager *m_playlistManager; diff --git a/mainwindow.ui b/mainwindow.ui index 3ca70e7..ca8070c 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -20,7 +20,7 @@ true - Pineapple Player + Pineapple Music @@ -315,25 +315,51 @@ QListView { - - - Drag and drop file to load + + + 0 - + + + + Drag and drop file to load + + + + + + + + 0 + 0 + + + + Lrc + + + + 20 + 20 + + + + + - 0:00 + 0:00 - 0:00 + 0:00 Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter