diff --git a/CMakeLists.txt b/CMakeLists.txt index 55d8b73231..f04248b8a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -566,6 +566,7 @@ set(SOURCES src/context/contextview.cpp src/context/contextalbum.cpp + src/context/syncedlyricswidget.cpp src/collection/collectionlibrary.cpp src/collection/collectionmodel.cpp @@ -695,6 +696,7 @@ set(SOURCES src/lyrics/letraslyricsprovider.cpp src/lyrics/lyricfindlyricsprovider.cpp src/lyrics/lrcliblyricsprovider.cpp + src/lyrics/lrcparser.cpp src/settings/settingsdialog.cpp src/settings/settingspage.cpp @@ -887,6 +889,7 @@ set(HEADERS src/context/contextview.h src/context/contextalbum.h + src/context/syncedlyricswidget.h src/collection/collectionlibrary.h src/collection/collectionmodel.h @@ -994,6 +997,8 @@ set(HEADERS src/lyrics/letraslyricsprovider.h src/lyrics/lyricfindlyricsprovider.h src/lyrics/lrcliblyricsprovider.h + src/lyrics/lrcparser.h + src/lyrics/lyricline.h src/settings/settingsdialog.h src/settings/settingspage.h diff --git a/src/context/contextview.cpp b/src/context/contextview.cpp index 0fefb13970..b942fa9bb2 100644 --- a/src/context/contextview.cpp +++ b/src/context/contextview.cpp @@ -37,12 +37,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -50,8 +52,12 @@ #include #include +#include + #include "core/song.h" +#include "core/player.h" #include "core/settings.h" +#include "engine/enginebase.h" #include "utilities/strutils.h" #include "utilities/timeutils.h" #include "widgets/resizabletextedit.h" @@ -63,6 +69,7 @@ #include "contextview.h" #include "contextalbum.h" +#include "syncedlyricswidget.h" using namespace Qt::Literals::StringLiterals; @@ -75,6 +82,7 @@ ContextView::ContextView(QWidget *parent) collectionview_(nullptr), album_cover_choice_controller_(nullptr), lyrics_fetcher_(nullptr), + lyrics_sync_timer_(new QTimer(this)), menu_options_(new QMenu(this)), action_show_album_(nullptr), action_show_data_(nullptr), @@ -95,6 +103,7 @@ ContextView::ContextView(QWidget *parent) widget_play_data_(new QWidget(this)), layout_play_data_(new QGridLayout()), textedit_play_lyrics_(new ResizableTextEdit(this)), + synced_lyrics_widget_(new SyncedLyricsWidget(this)), spacer_play_data_(new QSpacerItem(20, 20, QSizePolicy::Fixed, QSizePolicy::Fixed)), label_filetype_title_(new QLabel(this)), label_length_title_(new QLabel(this)), @@ -129,9 +138,22 @@ ContextView::ContextView(QWidget *parent) textedit_top_->setReadOnly(true); textedit_top_->setFrameShape(QFrame::NoFrame); + widget_header_ = new QWidget(this); + layout_header_ = new QHBoxLayout(); + layout_header_->setContentsMargins(0, 0, 0, 0); + widget_header_->setLayout(layout_header_); + + label_mini_album_ = new QLabel(this); + label_mini_album_->setFixedSize(64, 64); + label_mini_album_->setScaledContents(true); + label_mini_album_->hide(); + + layout_header_->addWidget(textedit_top_, 1); + layout_header_->addWidget(label_mini_album_); + layout_scrollarea_->setObjectName(u"context-layout-scrollarea"_s); layout_scrollarea_->setContentsMargins(15, 15, 15, 15); - layout_scrollarea_->addWidget(textedit_top_); + layout_scrollarea_->addWidget(widget_header_); layout_scrollarea_->addWidget(widget_album_); layout_scrollarea_->addWidget(widget_stacked_); layout_scrollarea_->addSpacerItem(new QSpacerItem(20, 20, QSizePolicy::Expanding, QSizePolicy::Expanding)); @@ -194,10 +216,14 @@ ContextView::ContextView(QWidget *parent) textedit_play_lyrics_->setFrameShape(QFrame::NoFrame); textedit_play_lyrics_->hide(); + synced_lyrics_widget_->hide(); + synced_lyrics_widget_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + layout_play_->setContentsMargins(0, 0, 0, 0); layout_play_->addWidget(widget_play_data_); layout_play_->addSpacerItem(spacer_play_data_); layout_play_->addWidget(textedit_play_lyrics_); + layout_play_->addWidget(synced_lyrics_widget_, 1); layout_play_->addSpacerItem(new QSpacerItem(20, 20, QSizePolicy::Expanding, QSizePolicy::Expanding)); labels_play_ << label_filetype_title_ @@ -220,14 +246,19 @@ ContextView::ContextView(QWidget *parent) } -void ContextView::Init(CollectionView *collectionview, AlbumCoverChoiceController *album_cover_choice_controller, SharedPtr lyrics_providers) { +void ContextView::Init(CollectionView *collectionview, AlbumCoverChoiceController *album_cover_choice_controller, SharedPtr lyrics_providers, SharedPtr player) { collectionview_ = collectionview; album_cover_choice_controller_ = album_cover_choice_controller; + player_ = player; widget_album_->Init(this, album_cover_choice_controller_); lyrics_fetcher_ = new LyricsFetcher(lyrics_providers, this); + lyrics_sync_timer_->setInterval(250); + QObject::connect(lyrics_sync_timer_, &QTimer::timeout, this, &ContextView::UpdateLyricsPosition); + QObject::connect(synced_lyrics_widget_, &SyncedLyricsWidget::SeekRequested, this, &ContextView::HandleLyricsSeek); + QObject::connect(collectionview_, &CollectionView::TotalSongCountUpdated_, this, &ContextView::UpdateNoSong); QObject::connect(collectionview_, &CollectionView::TotalArtistCountUpdated_, this, &ContextView::UpdateNoSong); QObject::connect(collectionview_, &CollectionView::TotalAlbumCountUpdated_, this, &ContextView::UpdateNoSong); @@ -313,17 +344,30 @@ void ContextView::resizeEvent(QResizeEvent *e) { widget_album_->UpdateWidth(width() - kWidgetSpacing); } + UpdateAlbumLayout(); + synced_lyrics_widget_->setMinimumHeight(e->size().height() * 2 / 3); + QWidget::resizeEvent(e); } -void ContextView::Playing() {} +void ContextView::Playing() { + + if (!synced_lyrics_.isEmpty() && action_show_lyrics_->isChecked()) { + lyrics_sync_timer_->start(); + } + +} void ContextView::Stopped() { song_playing_ = Song(); song_prev_ = Song(); lyrics_.clear(); + synced_lyrics_.clear(); + lyrics_sync_timer_->stop(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); image_original_ = QImage(); widget_album_->SetImage(); @@ -340,6 +384,8 @@ void ContextView::SongChanged(const Song &song) { song_prev_ = song_playing_; song_playing_ = song; lyrics_ = song.lyrics(); + synced_lyrics_.clear(); + lyrics_sync_timer_->stop(); lyrics_id_ = -1; lyrics_tried_ = false; SetSong(); @@ -414,6 +460,27 @@ void ContextView::UpdateFonts() { } +void ContextView::UpdateAlbumLayout() { + + if (!action_show_album_->isChecked()) { + widget_album_->hide(); + label_mini_album_->hide(); + return; + } + + constexpr int kCompactThreshold = 750; + + if (height() < kCompactThreshold) { + widget_album_->hide(); + label_mini_album_->show(); + } + else { + widget_album_->show(); + label_mini_album_->hide(); + } + +} + void ContextView::SetSong() { textedit_top_->setFont(font_headline_); @@ -421,16 +488,8 @@ void ContextView::SetSong() { label_stop_summary_->clear(); - bool widget_album_changed = !song_prev_.is_valid(); - if (action_show_album_->isChecked() && !widget_album_->isVisibleTo(this)) { - widget_album_->show(); - widget_album_changed = true; - } - else if (!action_show_album_->isChecked() && widget_album_->isVisibleTo(this)) { - widget_album_->hide(); - widget_album_changed = true; - } - if (widget_album_changed) Q_EMIT AlbumEnabledChanged(); + UpdateAlbumLayout(); + Q_EMIT AlbumEnabledChanged(); if (action_show_data_->isChecked()) { widget_play_data_->show(); @@ -487,13 +546,24 @@ void ContextView::SetSong() { spacer_play_data_->changeSize(0, 0, QSizePolicy::Fixed); } - if (action_show_lyrics_->isChecked() && !lyrics_.isEmpty()) { + if (action_show_lyrics_->isChecked() && !synced_lyrics_.isEmpty()) { + synced_lyrics_widget_->SetFonts(font_normal_); + synced_lyrics_widget_->SetLyrics(synced_lyrics_); + synced_lyrics_widget_->show(); + textedit_play_lyrics_->clear(); + textedit_play_lyrics_->hide(); + } + else if (action_show_lyrics_->isChecked() && !lyrics_.isEmpty()) { textedit_play_lyrics_->SetText(lyrics_); textedit_play_lyrics_->show(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); } else { textedit_play_lyrics_->clear(); textedit_play_lyrics_->hide(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); } widget_stacked_->setCurrentWidget(widget_play_); @@ -578,13 +648,18 @@ void ContextView::ResetSong() { widget_play_data_->hide(); textedit_play_lyrics_->hide(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); + lyrics_sync_timer_->stop(); } -void ContextView::UpdateLyrics(const quint64 id, const QString &provider, const QString &lyrics) { +void ContextView::UpdateLyrics(const quint64 id, const QString &provider, const QString &lyrics, const SyncedLyrics &synced_lyrics) { if (static_cast(id) != lyrics_id_) return; + synced_lyrics_ = synced_lyrics; + if (lyrics.isEmpty()) { lyrics_ = "No lyrics found.\n"_L1; } @@ -593,13 +668,27 @@ void ContextView::UpdateLyrics(const quint64 id, const QString &provider, const } lyrics_id_ = -1; - if (action_show_lyrics_->isChecked() && !lyrics_.isEmpty()) { + if (action_show_lyrics_->isChecked() && !synced_lyrics_.isEmpty()) { + synced_lyrics_widget_->SetFonts(font_normal_); + synced_lyrics_widget_->SetLyrics(synced_lyrics_); + synced_lyrics_widget_->show(); + textedit_play_lyrics_->clear(); + textedit_play_lyrics_->hide(); + lyrics_sync_timer_->start(); + } + else if (action_show_lyrics_->isChecked() && !lyrics_.isEmpty()) { textedit_play_lyrics_->SetText(lyrics_); textedit_play_lyrics_->show(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); + lyrics_sync_timer_->stop(); } else { textedit_play_lyrics_->clear(); textedit_play_lyrics_->hide(); + synced_lyrics_widget_->Clear(); + synced_lyrics_widget_->hide(); + lyrics_sync_timer_->stop(); } } @@ -641,6 +730,7 @@ void ContextView::AlbumCoverLoaded(const Song &song, const QImage &image) { widget_album_->SetImage(image); image_original_ = image; + label_mini_album_->setPixmap(QPixmap::fromImage(image)); } @@ -691,3 +781,30 @@ void ContextView::ActionSearchLyrics() { SearchLyrics(); } + +void ContextView::Paused() { + lyrics_sync_timer_->stop(); +} + +void ContextView::UpdateLyricsPosition() { + + if (!player_ || !player_->engine()) return; + const qint64 position_msec = player_->engine()->position_nanosec() / kNsecPerMsec; + synced_lyrics_widget_->SetPositionMsec(position_msec); + +} + +void ContextView::LyricsSeeked(const qint64 microseconds) { + + const qint64 position_msec = microseconds / kUsecPerMsec; + synced_lyrics_widget_->SetPositionMsec(position_msec); + +} + +void ContextView::HandleLyricsSeek(const qint64 msec) { + + if (player_) { + player_->SeekTo(static_cast(msec / kMsecPerSec)); + } + +} diff --git a/src/context/contextview.h b/src/context/contextview.h index 68580462a6..e6ea6f6329 100644 --- a/src/context/contextview.h +++ b/src/context/contextview.h @@ -31,10 +31,12 @@ #include #include "core/song.h" +#include "lyrics/lyricline.h" #include "contextalbum.h" class QMenu; class QLabel; +class QHBoxLayout; class QStackedWidget; class QVBoxLayout; class QGridLayout; @@ -45,9 +47,12 @@ class QContextMenuEvent; class QDragEnterEvent; class QDropEvent; +class QTimer; class ResizableTextEdit; +class SyncedLyricsWidget; class CollectionView; class AlbumCoverChoiceController; +class Player; class LyricsProviders; class LyricsFetcher; @@ -57,7 +62,7 @@ class ContextView : public QWidget { public: explicit ContextView(QWidget *parent = nullptr); - void Init(CollectionView *collectionview, AlbumCoverChoiceController *album_cover_choice_controller, SharedPtr lyrics_providers); + void Init(CollectionView *collectionview, AlbumCoverChoiceController *album_cover_choice_controller, SharedPtr lyrics_providers, SharedPtr player); ContextAlbum *album_widget() const { return widget_album_; } bool album_enabled() const { return action_show_album_->isChecked(); } @@ -79,6 +84,7 @@ class ContextView : public QWidget { void GetCoverAutomatically(); void SearchLyrics(); void UpdateFonts(); + void UpdateAlbumLayout(); Q_SIGNALS: void AlbumEnabledChanged(); @@ -90,20 +96,26 @@ class ContextView : public QWidget { void ActionSearchLyrics(); void UpdateNoSong(); void FadeStopFinished(); - void UpdateLyrics(const quint64 id, const QString &provider, const QString &lyrics); + void UpdateLyrics(const quint64 id, const QString &provider, const QString &lyrics, const SyncedLyrics &synced_lyrics); + void UpdateLyricsPosition(); + void HandleLyricsSeek(const qint64 msec); public Q_SLOTS: void ReloadSettings(); void Playing(); + void Paused(); void Stopped(); void Error(); + void LyricsSeeked(const qint64 microseconds); void SongChanged(const Song &song); void AlbumCoverLoaded(const Song &song, const QImage &image); private: CollectionView *collectionview_; AlbumCoverChoiceController *album_cover_choice_controller_; + SharedPtr player_; LyricsFetcher *lyrics_fetcher_; + QTimer *lyrics_sync_timer_; QMenu *menu_options_; QAction *action_show_album_; @@ -115,7 +127,10 @@ class ContextView : public QWidget { QWidget *widget_scrollarea_; QVBoxLayout *layout_scrollarea_; QScrollArea *scrollarea_; + QWidget *widget_header_; + QHBoxLayout *layout_header_; ResizableTextEdit *textedit_top_; + QLabel *label_mini_album_; ContextAlbum *widget_album_; QStackedWidget *widget_stacked_; QWidget *widget_stop_; @@ -126,6 +141,7 @@ class ContextView : public QWidget { QWidget *widget_play_data_; QGridLayout *layout_play_data_; ResizableTextEdit *textedit_play_lyrics_; + SyncedLyricsWidget *synced_lyrics_widget_; QSpacerItem *spacer_play_data_; @@ -147,6 +163,7 @@ class ContextView : public QWidget { bool lyrics_tried_; qint64 lyrics_id_; QString lyrics_; + SyncedLyrics synced_lyrics_; QString title_fmt_; QString summary_fmt_; QFont font_headline_; diff --git a/src/context/syncedlyricswidget.cpp b/src/context/syncedlyricswidget.cpp new file mode 100644 index 0000000000..1ed34b74db --- /dev/null +++ b/src/context/syncedlyricswidget.cpp @@ -0,0 +1,217 @@ +/* + * Strawberry Music Player + * Copyright 2026, guitaripod + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "syncedlyricswidget.h" + +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kUserScrollResumeMs = 3000; +constexpr int kScrollAnimationDurationMs = 400; +constexpr qreal kCurrentFontSizeMultiplier = 1.4; +} // namespace + +SyncedLyricsWidget::SyncedLyricsWidget(QWidget *parent) + : QListWidget(parent), + current_line_index_(-1), + is_user_scrolling_(false), + user_scroll_timer_(new QTimer(this)), + scroll_animation_(new QPropertyAnimation(this)) { + + setFrameShape(QFrame::NoFrame); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + setSelectionMode(QAbstractItemView::NoSelection); + setFocusPolicy(Qt::NoFocus); + setWordWrap(true); + setSpacing(8); + + scroll_animation_->setTargetObject(verticalScrollBar()); + scroll_animation_->setPropertyName("value"); + scroll_animation_->setDuration(kScrollAnimationDurationMs); + scroll_animation_->setEasingCurve(QEasingCurve::OutCubic); + + user_scroll_timer_->setSingleShot(true); + user_scroll_timer_->setInterval(kUserScrollResumeMs); + + QObject::connect(user_scroll_timer_, &QTimer::timeout, this, &SyncedLyricsWidget::UserScrollTimeout); + QObject::connect(this, &QListWidget::itemClicked, this, &SyncedLyricsWidget::ItemClicked); + +} + +void SyncedLyricsWidget::SetFonts(const QFont &base_font) { + + font_inactive_ = base_font; + font_inactive_.setWeight(QFont::Medium); + + font_current_ = base_font; + font_current_.setPointSizeF(base_font.pointSizeF() * kCurrentFontSizeMultiplier); + font_current_.setWeight(QFont::Bold); + +} + +void SyncedLyricsWidget::SetLyrics(const SyncedLyrics &lyrics) { + + clear(); + lyrics_ = lyrics; + current_line_index_ = -1; + is_user_scrolling_ = false; + scroll_animation_->stop(); + + const int spacer_height = qMax(200, height() / 2); + + QListWidgetItem *top_spacer = new QListWidgetItem(); + top_spacer->setFlags(Qt::NoItemFlags); + top_spacer->setSizeHint(QSize(0, spacer_height)); + addItem(top_spacer); + + for (int i = 0; i < lyrics_.size(); ++i) { + QListWidgetItem *item = new QListWidgetItem(lyrics_[i].text); + item->setData(Qt::UserRole, lyrics_[i].time_msec); + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + StyleItem(item, false, count()); + addItem(item); + } + + QListWidgetItem *bottom_spacer = new QListWidgetItem(); + bottom_spacer->setFlags(Qt::NoItemFlags); + bottom_spacer->setSizeHint(QSize(0, spacer_height)); + addItem(bottom_spacer); + + scrollToTop(); + +} + +void SyncedLyricsWidget::SetPositionMsec(const qint64 position_msec) { + + if (lyrics_.isEmpty()) return; + + int new_index = -1; + for (int i = 0; i < lyrics_.size(); ++i) { + if (lyrics_[i].time_msec <= position_msec) { + new_index = i; + } + else { + break; + } + } + + if (new_index != current_line_index_) { + UpdateCurrentLine(new_index); + } + +} + +void SyncedLyricsWidget::Clear() { + + clear(); + lyrics_.clear(); + current_line_index_ = -1; + is_user_scrolling_ = false; + scroll_animation_->stop(); + +} + +void SyncedLyricsWidget::UpdateCurrentLine(const int new_index) { + + current_line_index_ = new_index; + + const int item_offset = 1; + + for (int i = 0; i < lyrics_.size(); ++i) { + const int widget_index = i + item_offset; + if (i == current_line_index_) { + StyleItem(item(widget_index), true, 0); + } + else { + const int distance = current_line_index_ >= 0 ? qAbs(i - current_line_index_) : lyrics_.size(); + StyleItem(item(widget_index), false, distance); + } + } + + if (current_line_index_ >= 0 && current_line_index_ < lyrics_.size()) { + if (!is_user_scrolling_) { + SmoothScrollToItem(current_line_index_ + item_offset); + } + } + +} + +void SyncedLyricsWidget::SmoothScrollToItem(const int index) { + + QListWidgetItem *target = item(index); + if (!target) return; + + const QRect item_rect = visualItemRect(target); + const int viewport_center = viewport()->height() / 2; + const int item_center = item_rect.top() + item_rect.height() / 2; + const int target_value = verticalScrollBar()->value() + item_center - viewport_center; + const int clamped = qBound(verticalScrollBar()->minimum(), target_value, verticalScrollBar()->maximum()); + + if (scroll_animation_->state() == QAbstractAnimation::Running) { + scroll_animation_->stop(); + } + + scroll_animation_->setStartValue(verticalScrollBar()->value()); + scroll_animation_->setEndValue(clamped); + scroll_animation_->start(); + +} + +void SyncedLyricsWidget::StyleItem(QListWidgetItem *item, bool is_current, int distance) { + + if (is_current) { + item->setFont(font_current_); + item->setForeground(palette().color(QPalette::Text)); + } + else { + item->setFont(font_inactive_); + QColor color = palette().color(QPalette::Text); + const int alpha = qMax(60, 160 - distance * 25); + color.setAlpha(alpha); + item->setForeground(color); + } + +} + +void SyncedLyricsWidget::ItemClicked(QListWidgetItem *item) { + + const qint64 time_msec = item->data(Qt::UserRole).toLongLong(); + Q_EMIT SeekRequested(time_msec); + +} + +void SyncedLyricsWidget::wheelEvent(QWheelEvent *e) { + + is_user_scrolling_ = true; + user_scroll_timer_->start(); + scroll_animation_->stop(); + QListWidget::wheelEvent(e); + +} + +void SyncedLyricsWidget::UserScrollTimeout() { + is_user_scrolling_ = false; +} diff --git a/src/context/syncedlyricswidget.h b/src/context/syncedlyricswidget.h new file mode 100644 index 0000000000..6eca7dedf8 --- /dev/null +++ b/src/context/syncedlyricswidget.h @@ -0,0 +1,64 @@ +/* + * Strawberry Music Player + * Copyright 2026, guitaripod + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SYNCEDLYRICSWIDGET_H +#define SYNCEDLYRICSWIDGET_H + +#include +#include +#include +#include + +#include "lyrics/lyricline.h" + +class SyncedLyricsWidget : public QListWidget { + Q_OBJECT + + public: + explicit SyncedLyricsWidget(QWidget *parent = nullptr); + + void SetLyrics(const SyncedLyrics &lyrics); + void SetPositionMsec(const qint64 position_msec); + void Clear(); + void SetFonts(const QFont &base_font); + + Q_SIGNALS: + void SeekRequested(const qint64 msec); + + protected: + void wheelEvent(QWheelEvent *e) override; + private Q_SLOTS: + void ItemClicked(QListWidgetItem *item); + void UserScrollTimeout(); + + private: + void UpdateCurrentLine(const int new_index); + void StyleItem(QListWidgetItem *item, bool is_current, int distance); + void SmoothScrollToItem(const int index); + + SyncedLyrics lyrics_; + int current_line_index_; + bool is_user_scrolling_; + QTimer *user_scroll_timer_; + QPropertyAnimation *scroll_animation_; + QFont font_current_; + QFont font_inactive_; +}; + +#endif // SYNCEDLYRICSWIDGET_H diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index b54e5f0028..2d6645a13a 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -419,7 +419,7 @@ MainWindow::MainWindow(Application *app, album_cover_choice_controller_->Init(app->network(), app->tagreader_client(), app->collection()->backend(), app->albumcover_loader(), app->current_albumcover_loader(), app->cover_providers(), app->streaming_services()); ui_->multi_loading_indicator->SetTaskManager(app_->task_manager()); - context_view_->Init(collection_view_->view(), album_cover_choice_controller_, app_->lyrics_providers()); + context_view_->Init(collection_view_->view(), album_cover_choice_controller_, app_->lyrics_providers(), app_->player()); ui_->widget_playing->Init(album_cover_choice_controller_); // Initialize the search widget @@ -910,7 +910,10 @@ MainWindow::MainWindow(Application *app, QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongMetadataChanged, context_view_, &ContextView::SongChanged); QObject::connect(&*app_->player(), &Player::PlaylistFinished, context_view_, &ContextView::Stopped); QObject::connect(&*app_->player(), &Player::Playing, context_view_, &ContextView::Playing); + QObject::connect(&*app_->player(), &Player::Paused, context_view_, &ContextView::Paused); + QObject::connect(&*app_->player(), &Player::Resumed, context_view_, &ContextView::Playing); QObject::connect(&*app_->player(), &Player::Stopped, context_view_, &ContextView::Stopped); + QObject::connect(&*app_->player(), &Player::Seeked, context_view_, &ContextView::LyricsSeeked); QObject::connect(&*app_->player(), &Player::Error, context_view_, &ContextView::Error); QObject::connect(this, &MainWindow::AlbumCoverReady, context_view_, &ContextView::AlbumCoverLoaded); QObject::connect(this, &MainWindow::SearchCoverInProgress, context_view_->album_widget(), &ContextAlbum::SearchCoverInProgress); diff --git a/src/lyrics/lrcliblyricsprovider.cpp b/src/lyrics/lrcliblyricsprovider.cpp index fdccf084b1..e22646bc67 100644 --- a/src/lyrics/lrcliblyricsprovider.cpp +++ b/src/lyrics/lrcliblyricsprovider.cpp @@ -35,6 +35,7 @@ #include "core/logging.h" #include "core/networkaccessmanager.h" #include "jsonlyricsprovider.h" +#include "lrcparser.h" #include "lyricssearchrequest.h" #include "lyricssearchresult.h" #include "lrcliblyricsprovider.h" @@ -150,6 +151,14 @@ void LrcLibLyricsProvider::HandleSearchReply(QNetworkReply *reply, const int id, result.album = json_object["albumName"_L1].toString(); result.title = json_object["trackName"_L1].toString(); result.lyrics = json_object["plainLyrics"_L1].toString(); + + if (json_object.contains("syncedLyrics"_L1) && json_object["syncedLyrics"_L1].isString()) { + const QString synced_lyrics_raw = json_object["syncedLyrics"_L1].toString(); + if (!synced_lyrics_raw.isEmpty()) { + result.synced_lyrics = LrcParser::Parse(synced_lyrics_raw); + } + } + results << result; } diff --git a/src/lyrics/lrcparser.cpp b/src/lyrics/lrcparser.cpp new file mode 100644 index 0000000000..d4996f353f --- /dev/null +++ b/src/lyrics/lrcparser.cpp @@ -0,0 +1,56 @@ +/* + * Strawberry Music Player + * Copyright 2026, guitaripod + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "lrcparser.h" + +#include + +#include +#include + +SyncedLyrics LrcParser::Parse(const QString &lrc_text) { + + static const QRegularExpression re(QStringLiteral("^\\[(\\d+):(\\d+\\.?\\d*)\\](.*)$")); + + SyncedLyrics result; + const QStringList lines = lrc_text.split(QLatin1Char('\n')); + + for (const QString &line : lines) { + const QRegularExpressionMatch match = re.match(line.trimmed()); + if (!match.hasMatch()) continue; + + const qint64 minutes = match.captured(1).toLongLong(); + const double seconds = match.captured(2).toDouble(); + const QString text = match.captured(3).trimmed(); + + if (text.isEmpty()) continue; + + LyricLine lyric_line; + lyric_line.time_msec = minutes * 60000 + static_cast(seconds * 1000.0); + lyric_line.text = text; + result.append(lyric_line); + } + + std::sort(result.begin(), result.end(), [](const LyricLine &a, const LyricLine &b) { + return a.time_msec < b.time_msec; + }); + + return result; + +} diff --git a/src/lyrics/lrcparser.h b/src/lyrics/lrcparser.h new file mode 100644 index 0000000000..d6ac6aba2d --- /dev/null +++ b/src/lyrics/lrcparser.h @@ -0,0 +1,32 @@ +/* + * Strawberry Music Player + * Copyright 2026, guitaripod + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef LRCPARSER_H +#define LRCPARSER_H + +#include + +#include "lyricline.h" + +class LrcParser { + public: + static SyncedLyrics Parse(const QString &lrc_text); +}; + +#endif // LRCPARSER_H diff --git a/src/lyrics/lyricline.h b/src/lyrics/lyricline.h new file mode 100644 index 0000000000..5cca903973 --- /dev/null +++ b/src/lyrics/lyricline.h @@ -0,0 +1,37 @@ +/* + * Strawberry Music Player + * Copyright 2026, guitaripod + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef LYRICLINE_H +#define LYRICLINE_H + +#include +#include +#include +#include + +struct LyricLine { + qint64 time_msec; + QString text; +}; +using SyncedLyrics = QList; + +Q_DECLARE_METATYPE(LyricLine) +Q_DECLARE_METATYPE(SyncedLyrics) + +#endif // LYRICLINE_H diff --git a/src/lyrics/lyricsfetcher.cpp b/src/lyrics/lyricsfetcher.cpp index d2a0013d69..8c0e47c281 100644 --- a/src/lyrics/lyricsfetcher.cpp +++ b/src/lyrics/lyricsfetcher.cpp @@ -122,12 +122,12 @@ void LyricsFetcher::SingleSearchFinished(const quint64 request_id, const LyricsS } -void LyricsFetcher::SingleLyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics) { +void LyricsFetcher::SingleLyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics, const SyncedLyrics &synced_lyrics) { if (!active_requests_.contains(request_id)) return; LyricsFetcherSearch *search = active_requests_.take(request_id); search->deleteLater(); - Q_EMIT LyricsFetched(request_id, provider, lyrics); + Q_EMIT LyricsFetched(request_id, provider, lyrics, synced_lyrics); } diff --git a/src/lyrics/lyricsfetcher.h b/src/lyrics/lyricsfetcher.h index 3b49aef313..eb73b10a51 100644 --- a/src/lyrics/lyricsfetcher.h +++ b/src/lyrics/lyricsfetcher.h @@ -33,6 +33,7 @@ #include #include "includes/shared_ptr.h" +#include "lyricline.h" #include "lyricssearchrequest.h" #include "lyricssearchresult.h" @@ -60,12 +61,12 @@ class LyricsFetcher : public QObject { void AddRequest(const Request &request); Q_SIGNALS: - void LyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics); + void LyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics, const SyncedLyrics &synced_lyrics); void SearchFinished(const quint64 request_id, const LyricsSearchResults &results); private Q_SLOTS: void SingleSearchFinished(const quint64 request_id, const LyricsSearchResults &results); - void SingleLyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics); + void SingleLyricsFetched(const quint64 request_id, const QString &provider, const QString &lyrics, const SyncedLyrics &synced_lyrics); void StartRequests(); private: diff --git a/src/lyrics/lyricsfetchersearch.cpp b/src/lyrics/lyricsfetchersearch.cpp index a13e423ce1..694af025a3 100644 --- a/src/lyrics/lyricsfetchersearch.cpp +++ b/src/lyrics/lyricsfetchersearch.cpp @@ -38,7 +38,7 @@ using namespace Qt::Literals::StringLiterals; namespace { constexpr int kSearchTimeoutMs = 5000; constexpr int kGoodLyricsLength = 60; -constexpr float kHighScore = 2.5; +constexpr float kHighScore = 3.0; } // namespace LyricsFetcherSearch::LyricsFetcherSearch(const quint64 id, const LyricsSearchRequest &request, QObject *parent) @@ -118,6 +118,7 @@ void LyricsFetcherSearch::ProviderSearchFinished(const int id, const LyricsSearc results_copy[i].score -= 1.5; } if (results_copy[i].lyrics.length() > kGoodLyricsLength) results_copy[i].score += 1.0; + if (!results_copy[i].synced_lyrics.isEmpty()) results_copy[i].score += 0.5; if (results_copy[i].score > higest_score) higest_score = results_copy[i].score; } @@ -164,7 +165,7 @@ void LyricsFetcherSearch::FinishSearch() { } else { qLog(Debug) << "Using lyrics from" << results_.last().provider << "for" << request_.artist << request_.title << "with score" << results_.last().score; - Q_EMIT LyricsFetched(id_, results_.constLast().provider, results_.constLast().lyrics); + Q_EMIT LyricsFetched(id_, results_.constLast().provider, results_.constLast().lyrics, results_.constLast().synced_lyrics); } Q_EMIT SearchFinished(id_, results_); diff --git a/src/lyrics/lyricsfetchersearch.h b/src/lyrics/lyricsfetchersearch.h index 6d1187cf2b..8ed14a413a 100644 --- a/src/lyrics/lyricsfetchersearch.h +++ b/src/lyrics/lyricsfetchersearch.h @@ -28,6 +28,7 @@ #include #include "includes/shared_ptr.h" +#include "lyricline.h" #include "lyricssearchrequest.h" #include "lyricssearchresult.h" @@ -46,7 +47,7 @@ class LyricsFetcherSearch : public QObject { Q_SIGNALS: void SearchFinished(const quint64 id, const LyricsSearchResults &results); - void LyricsFetched(const quint64 id, const QString &provider = QString(), const QString &lyrics = QString()); + void LyricsFetched(const quint64 id, const QString &provider = QString(), const QString &lyrics = QString(), const SyncedLyrics &synced_lyrics = SyncedLyrics()); private Q_SLOTS: void ProviderSearchFinished(const int id, const LyricsSearchResults &results); diff --git a/src/lyrics/lyricssearchresult.h b/src/lyrics/lyricssearchresult.h index 68185ad3a9..ccaa79aca9 100644 --- a/src/lyrics/lyricssearchresult.h +++ b/src/lyrics/lyricssearchresult.h @@ -24,6 +24,8 @@ #include #include +#include "lyricline.h" + class LyricsSearchResult { public: explicit LyricsSearchResult(const QString &_lyrics = QString()) : lyrics(_lyrics), score(0.0) {} @@ -32,6 +34,7 @@ class LyricsSearchResult { QString album; QString title; QString lyrics; + SyncedLyrics synced_lyrics; float score; }; using LyricsSearchResults = QList;