Added basic lyrics display in UI

This commit is contained in:
emeric
2024-10-27 23:03:46 +01:00
parent f613d16fc2
commit 41504bf4d7
17 changed files with 227 additions and 19 deletions
+1
View File
@@ -184,6 +184,7 @@
<message id="Lms.Explore.star">Star</message> <message id="Lms.Explore.star">Star</message>
<message id="Lms.Explore.starred">Starred</message> <message id="Lms.Explore.starred">Starred</message>
<message id="Lms.Explore.track-info">Track information</message> <message id="Lms.Explore.track-info">Track information</message>
<message id="Lms.Explore.track-lyrics">Lyrics</message>
<message id="Lms.Explore.tracklists">Playlists</message> <message id="Lms.Explore.tracklists">Playlists</message>
<message id="Lms.Explore.tracks">Tracks</message> <message id="Lms.Explore.tracks">Tracks</message>
<message id="Lms.Explore.type">Type</message> <message id="Lms.Explore.type">Type</message>
+1
View File
@@ -184,6 +184,7 @@
<message id="Lms.Explore.star">Ajouter aux favoris</message> <message id="Lms.Explore.star">Ajouter aux favoris</message>
<message id="Lms.Explore.starred">Favoris</message> <message id="Lms.Explore.starred">Favoris</message>
<message id="Lms.Explore.track-info">Informations sur la piste</message> <message id="Lms.Explore.track-info">Informations sur la piste</message>
<message id="Lms.Explore.track-lyrics">Paroles</message>
<message id="Lms.Explore.tracklists">Playlists</message> <message id="Lms.Explore.tracklists">Playlists</message>
<message id="Lms.Explore.tracks">Pistes</message> <message id="Lms.Explore.tracks">Pistes</message>
<message id="Lms.Explore.type">Type</message> <message id="Lms.Explore.type">Type</message>
+1
View File
@@ -184,6 +184,7 @@
<message id="Lms.Explore.star">Aggiungi ai preferiti</message> <message id="Lms.Explore.star">Aggiungi ai preferiti</message>
<message id="Lms.Explore.starred">Preferiti</message> <message id="Lms.Explore.starred">Preferiti</message>
<message id="Lms.Explore.track-info">Informazioni sulla traccia</message> <message id="Lms.Explore.track-info">Informazioni sulla traccia</message>
<message id="Lms.Explore.track-lyrics">Testo</message>
<message id="Lms.Explore.tracklists">Playlist</message> <message id="Lms.Explore.tracklists">Playlist</message>
<message id="Lms.Explore.tracks">Tracce</message> <message id="Lms.Explore.tracks">Tracce</message>
<message id="Lms.Explore.type">Tipo</message> <message id="Lms.Explore.type">Tipo</message>
+1
View File
@@ -201,6 +201,7 @@
<message id="Lms.Explore.star">Wyróżnij</message> <message id="Lms.Explore.star">Wyróżnij</message>
<message id="Lms.Explore.starred">Wyróżnione</message> <message id="Lms.Explore.starred">Wyróżnione</message>
<message id="Lms.Explore.track-info">Informacje o ścieżce</message> <message id="Lms.Explore.track-info">Informacje o ścieżce</message>
<message id="Lms.Explore.track-lyrics">Tekst</message>
<message id="Lms.Explore.tracklists">Listy odtwarzania</message> <message id="Lms.Explore.tracklists">Listy odtwarzania</message>
<message id="Lms.Explore.tracks">Ścieżki</message> <message id="Lms.Explore.tracks">Ścieżki</message>
<message id="Lms.Explore.type">Rodzaj</message> <message id="Lms.Explore.type">Rodzaj</message>
+1
View File
@@ -184,6 +184,7 @@
<message id="Lms.Explore.star">收藏</message> <message id="Lms.Explore.star">收藏</message>
<message id="Lms.Explore.starred">已收藏</message> <message id="Lms.Explore.starred">已收藏</message>
<message id="Lms.Explore.tracklists">播放列表</message> <message id="Lms.Explore.tracklists">播放列表</message>
<message id="Lms.Explore.tracks">音轨</message> <message id="Lms.Explore.tracks">音轨</message>
<message id="Lms.Explore.type">类型</message> <message id="Lms.Explore.type">类型</message>
+1
View File
@@ -102,6 +102,7 @@
<li>${star class="dropdown-item"}</li> <li>${star class="dropdown-item"}</li>
<li>${download class="dropdown-item"}</li> <li>${download class="dropdown-item"}</li>
<li>${track-info class="dropdown-item"}</li> <li>${track-info class="dropdown-item"}</li>
${<if-has-lyrics>}<li>${track-lyrics class="dropdown-item"}</li>${</if-has-lyrics>}
</ul> </ul>
</div> </div>
</div> </div>
+21
View File
@@ -60,6 +60,7 @@
<li>${star class="dropdown-item"}</li> <li>${star class="dropdown-item"}</li>
<li>${download class="dropdown-item"}</li> <li>${download class="dropdown-item"}</li>
<li>${track-info class="dropdown-item"}</li> <li>${track-info class="dropdown-item"}</li>
${<if-has-lyrics>}<li>${track-lyrics class="dropdown-item"}</li>${</if-has-lyrics>}
</ul> </ul>
</div> </div>
</div> </div>
@@ -139,4 +140,24 @@
</div> </div>
</message> </message>
<message id="Lms.Explore.Tracks.template.track-lyrics">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${tr:Lms.Explore.track-lyrics}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
${lyrics}
</div>
<div class="modal-footer">
${ok-btn class="btn btn-primary"}
</div>
</div>
</div>
</div>
</message>
</messages> </messages>
+5
View File
@@ -441,6 +441,11 @@ namespace lms::db
_trackLyrics.insert(getDboPtr(lyrics)); _trackLyrics.insert(getDboPtr(lyrics));
} }
bool Track::hasLyrics() const
{
return !_trackLyrics.empty();
}
std::optional<std::string> Track::getCopyright() const std::optional<std::string> Track::getCopyright() const
{ {
return _copyright != "" ? std::make_optional<std::string>(_copyright) : std::nullopt; return _copyright != "" ? std::make_optional<std::string>(_copyright) : std::nullopt;
+31 -6
View File
@@ -34,6 +34,34 @@
namespace lms::db namespace lms::db
{ {
namespace
{
Wt::Dbo::Query<Wt::Dbo::ptr<TrackLyrics>> createQuery(Session& session, const TrackLyrics::FindParameters& params)
{
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc") };
if (params.track.isValid())
query.where("t_lrc.track_id = ?").bind(params.track);
if (params.external.has_value())
query.where("t_lrc.absolute_file_path " + std::string{ *params.external ? "<>" : "=" } + " ''");
switch (params.sortMethod)
{
case TrackLyricsSortMethod::None:
break;
case TrackLyricsSortMethod::ExternalFirst:
query.orderBy("CASE WHEN absolute_file_path <> '' THEN 0 ELSE 1 END");
break;
case TrackLyricsSortMethod::EmbeddedFirst:
query.orderBy("CASE WHEN absolute_file_path = '' THEN 0 ELSE 1 END");
break;
}
return query;
}
} // namespace
TrackLyrics::pointer TrackLyrics::create(Session& session) TrackLyrics::pointer TrackLyrics::create(Session& session)
{ {
return session.getDboSession()->add(std::unique_ptr<TrackLyrics>{ new TrackLyrics{} }); return session.getDboSession()->add(std::unique_ptr<TrackLyrics>{ new TrackLyrics{} });
@@ -67,15 +95,12 @@ namespace lms::db
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.absolute_file_path = ?").bind(path)); return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.absolute_file_path = ?").bind(path));
} }
void TrackLyrics::find(Session& session, TrackId trackId, const std::function<void(const TrackLyrics::pointer&)>& func) void TrackLyrics::find(Session& session, const FindParameters& params, const std::function<void(const TrackLyrics::pointer&)>& func)
{ {
session.checkReadTransaction(); session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.track_id = ?").bind(trackId) }; auto query{ createQuery(session, params) };
utils::forEachQueryRangeResult(query, params.range, func);
utils::forEachQueryResult(query, [&](const TrackLyrics::pointer& lyrics) {
func(lyrics);
});
} }
void TrackLyrics::find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func) void TrackLyrics::find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func)
@@ -274,6 +274,7 @@ namespace lms::db
const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; }
const Wt::WDateTime& getAddedTime() const { return _fileAdded; } const Wt::WDateTime& getAddedTime() const { return _fileAdded; }
bool hasCover() const { return _hasCover; } bool hasCover() const { return _hasCover; }
bool hasLyrics() const;
std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); } std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); }
std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); } std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); }
std::optional<std::string> getCopyright() const; std::optional<std::string> getCopyright() const;
@@ -45,12 +45,41 @@ namespace lms::db
public: public:
TrackLyrics() = default; TrackLyrics() = default;
struct FindParameters
{
std::optional<Range> range;
TrackId track;
std::optional<bool> external; // if set, true means external, false means embedded
TrackLyricsSortMethod sortMethod{ TrackLyricsSortMethod::None };
FindParameters& setRange(std::optional<Range> _range)
{
range = _range;
return *this;
}
FindParameters& setTrack(TrackId _track)
{
track = _track;
return *this;
}
FindParameters& setExternal(std::optional<bool> _external)
{
external = _external;
return *this;
}
FindParameters& setSortMethod(TrackLyricsSortMethod _sortMethod)
{
sortMethod = _sortMethod;
return *this;
}
};
// Find utilities // Find utilities
static std::size_t getCount(Session& session); static std::size_t getCount(Session& session);
static std::size_t getExternalLyricsCount(Session& session); static std::size_t getExternalLyricsCount(Session& session);
static pointer find(Session& session, TrackLyricsId id); static pointer find(Session& session, TrackLyricsId id);
static pointer find(Session& session, const std::filesystem::path& file); static pointer find(Session& session, const std::filesystem::path& file);
static void find(Session& session, TrackId trackId, const std::function<void(const TrackLyrics::pointer&)>& func); static void find(Session& session, const FindParameters& params, const std::function<void(const TrackLyrics::pointer&)>& func);
static void find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func); static void find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func);
static RangeResults<TrackLyricsId> findOrphanIds(Session& session, std::optional<Range> range); static RangeResults<TrackLyricsId> findOrphanIds(Session& session, std::optional<Range> range);
@@ -166,6 +166,13 @@ namespace lms::db
TrackList, // order by asc order in tracklist TrackList, // order by asc order in tracklist
}; };
enum class TrackLyricsSortMethod
{
None,
ExternalFirst,
EmbeddedFirst,
};
enum class TrackArtistLinkType enum class TrackArtistLinkType
{ {
Artist = 0, // regular track artist Artist = 0, // regular track artist
+36 -1
View File
@@ -157,12 +157,47 @@ namespace lms::db::tests
EXPECT_EQ(TrackLyrics::getCount(session), 1); EXPECT_EQ(TrackLyrics::getCount(session), 1);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1); EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1);
{
bool visited{}; bool visited{};
TrackLyrics::find(session, track.getId(), [&](const TrackLyrics::pointer& lyrics) { TrackLyrics::find(session, TrackLyrics::FindParameters{}.setTrack(track.getId()), [&](const TrackLyrics::pointer& lyrics) {
EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc"); EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc");
visited = true; visited = true;
}); });
EXPECT_TRUE(visited); EXPECT_TRUE(visited);
} }
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(true), [&](const TrackLyrics::pointer& lyrics) {
EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc");
visited = true;
});
EXPECT_TRUE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(false), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_FALSE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::EmbeddedFirst), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_TRUE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::ExternalFirst), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_TRUE(visited);
}
}
} }
} // namespace lms::db::tests } // namespace lms::db::tests
@@ -211,12 +211,13 @@ namespace lms::api::subsonic
if (tracks.results.size() == 1) if (tracks.results.size() == 1)
{ {
// Choice: we return only the first lyrics if the track has many lyrics // Choice: we return only the first lyrics if the track has many lyrics
bool lyricsSet{}; db::TrackLyrics::FindParameters params;
db::TrackLyrics::find(context.dbSession, tracks.results[0], [&](const db::TrackLyrics::pointer& lyrics) { params.setTrack(tracks.results[0]);
if (lyricsSet) params.setSortMethod(TrackLyricsSortMethod::ExternalFirst);
return; params.setRange(db::Range{ 0, 1 });
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
response.addNode("lyrics", createLyricsNode(context, lyrics)); response.addNode("lyrics", createLyricsNode(context, lyrics));
lyricsSet = true;
}); });
} }
@@ -236,10 +237,24 @@ namespace lms::api::subsonic
const db::Track::pointer track{ db::Track::find(context.dbSession, id) }; const db::Track::pointer track{ db::Track::find(context.dbSession, id) };
if (track) if (track)
{ {
db::TrackLyrics::find(context.dbSession, track->getId(), [&](const db::TrackLyrics::pointer& lyrics) { db::TrackLyrics::FindParameters params;
params.setTrack(track->getId());
params.setExternal(true); // First try to only report external lyrics as they are often duplicate of embedded lyrics and support more features
bool hasExternalLyrics{};
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
hasExternalLyrics = true;
});
if (!hasExternalLyrics)
{
params.setExternal(false);
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
}); });
} }
}
return response; return response;
} }
+8
View File
@@ -466,6 +466,14 @@ namespace lms::ui
entry->bindNew<Wt::WPushButton>("track-info", Wt::WString::tr("Lms.Explore.track-info")) entry->bindNew<Wt::WPushButton>("track-info", Wt::WString::tr("Lms.Explore.track-info"))
->clicked() ->clicked()
.connect([this, trackId] { TrackListHelpers::showTrackInfoModal(trackId, _filters); }); .connect([this, trackId] { TrackListHelpers::showTrackInfoModal(trackId, _filters); });
if (track->hasLyrics())
{
entry->setCondition("if-has-lyrics", true);
entry->bindNew<Wt::WPushButton>("track-lyrics", Wt::WString::tr("Lms.Explore.track-lyrics"))
->clicked()
.connect([trackId] { TrackListHelpers::showTrackLyricsModal(trackId); });
}
} }
entry->bindString("duration", utils::durationToString(track->getDuration()), Wt::TextFormat::Plain); entry->bindString("duration", utils::durationToString(track->getDuration()), Wt::TextFormat::Plain);
+55
View File
@@ -33,6 +33,7 @@
#include "database/Session.hpp" #include "database/Session.hpp"
#include "database/Track.hpp" #include "database/Track.hpp"
#include "database/TrackArtistLink.hpp" #include "database/TrackArtistLink.hpp"
#include "database/TrackLyrics.hpp"
#include "database/User.hpp" #include "database/User.hpp"
#include "services/feedback/IFeedbackService.hpp" #include "services/feedback/IFeedbackService.hpp"
#include "services/scrobbling/IScrobblingService.hpp" #include "services/scrobbling/IScrobblingService.hpp"
@@ -155,6 +156,52 @@ namespace lms::ui::TrackListHelpers
LmsApp->getModalManager().show(std::move(trackInfo)); LmsApp->getModalManager().show(std::move(trackInfo));
} }
void showTrackLyricsModal(db::TrackId trackId)
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
auto trackLyrics{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.track-lyrics")) };
Wt::WWidget* trackLyricsPtr{ trackLyrics.get() };
trackLyrics->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WContainerWidget* lyricsContainer{ trackLyrics->bindNew<Wt::WContainerWidget>("lyrics") };
// limitation: display only first lyrics for now
db::TrackLyrics::FindParameters params;
params.setTrack(trackId);
params.setSortMethod(db::TrackLyricsSortMethod::ExternalFirst);
params.setRange(db::Range{ 0, 1 });
db::TrackLyrics::find(LmsApp->getDbSession(), params, [&](const db::TrackLyrics::pointer& lyrics) {
std::string text;
auto addLine = [&](std::string_view line) {
if (!text.empty())
text += '\n';
text += line;
};
if (lyrics->isSynchronized())
{
for (const auto& [timestamp, line] : lyrics->getSynchronizedLines())
addLine(line);
}
else
{
for (const auto& line : lyrics->getUnsynchronizedLines())
addLine(line);
}
lyricsContainer->addNew<Wt::WText>(Wt::WString::fromUTF8(text), Wt::TextFormat::Plain);
});
Wt::WPushButton* okBtn{ trackLyrics->bindNew<Wt::WPushButton>("ok-btn", Wt::WString::tr("Lms.ok")) };
okBtn->clicked().connect([=] {
LmsApp->getModalManager().dispose(trackLyricsPtr);
});
LmsApp->getModalManager().show(std::move(trackLyrics));
}
std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters) std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters)
{ {
auto entry{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.entry")) }; auto entry{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.entry")) };
@@ -240,6 +287,14 @@ namespace lms::ui::TrackListHelpers
->clicked() ->clicked()
.connect([trackId, &filters] { showTrackInfoModal(trackId, filters); }); .connect([trackId, &filters] { showTrackInfoModal(trackId, filters); });
if (track->hasLyrics())
{
entry->setCondition("if-has-lyrics", true);
entry->bindNew<Wt::WPushButton>("track-lyrics", Wt::WString::tr("Lms.Explore.track-lyrics"))
->clicked()
.connect([trackId] { showTrackLyricsModal(trackId); });
}
LmsApp->getMediaPlayer().trackLoaded.connect(entryPtr, [=](db::TrackId loadedTrackId) { LmsApp->getMediaPlayer().trackLoaded.connect(entryPtr, [=](db::TrackId loadedTrackId) {
entryPtr->toggleStyleClass("Lms-entry-playing", loadedTrackId == trackId); entryPtr->toggleStyleClass("Lms-entry-playing", loadedTrackId == trackId);
}); });
+1
View File
@@ -40,5 +40,6 @@ namespace lms::ui
namespace lms::ui::TrackListHelpers namespace lms::ui::TrackListHelpers
{ {
void showTrackInfoModal(db::TrackId trackId, Filters& filters); void showTrackInfoModal(db::TrackId trackId, Filters& filters);
void showTrackLyricsModal(db::TrackId trackId);
std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters); std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters);
} // namespace lms::ui::TrackListHelpers } // namespace lms::ui::TrackListHelpers