Commit 6463cde6 authored by Han Young's avatar Han Young
Browse files

Parse lyric and display them

change branch name to work/simpleLRC, original MR: multimedia/elisa!231
parent 8591836b
Pipeline #175381 passed with stage
in 4 minutes and 40 seconds
......@@ -34,6 +34,7 @@ set(elisaLib_SOURCES
models/trackcontextmetadatamodel.cpp
models/viewsmodel.cpp
models/viewsproxymodel.cpp
models/lyricsmodel.cpp
viewslistdata.cpp
viewconfigurationdata.cpp
localFileConfiguration/elisaconfigurationdialog.cpp
......
......@@ -50,6 +50,7 @@
#include "models/viewsmodel.h"
#include "models/viewsproxymodel.h"
#include "models/gridviewproxymodel.h"
#include "models/lyricsmodel.h"
#include "localFileConfiguration/elisaconfigurationdialog.h"
#if KF5FileMetaData_FOUND
......@@ -132,6 +133,7 @@ void ElisaQmlTestPlugin::registerTypes(const char *uri)
qmlRegisterType<ViewsProxyModel>(uri, 1, 0, "ViewsProxyModel");
qmlRegisterType<ViewsListData>(uri, 1, 0, "ViewsListData");
qmlRegisterType<GridViewProxyModel>(uri, 1, 0, "GridViewProxyModel");
qmlRegisterType<LyricsModel>(uri, 1, 0, "LyricsModel");
#if KF5KIO_FOUND
qmlRegisterType<FileBrowserModel>(uri, 1, 0, "FileBrowserModel");
......
/*
SPDX-FileCopyrightText: 2021 Han Young <hanyoung@protonmail.com>
SPDX-License-Identifier: LGPL-3.0-or-later
*/
#include "lyricsmodel.h"
#include <QDebug>
#include <algorithm>
#include <unordered_map>
#include <KLocalizedString>
class LyricsModel::LyricsModelPrivate
{
public:
bool parse(const QString &lyric);
int highlightedIndex{-1};
bool isLRC {false};
std::vector<std::pair<QString, qint64>> lyrics;
private:
qint64 parseOneTimeStamp(QString::const_iterator &begin, QString::const_iterator end);
QString parseOneLine(QString::const_iterator &begin, QString::const_iterator end);
QString parseTags(QString::const_iterator &begin, QString::const_iterator end);
qint64 offset = 0;
};
/*###########parseOneTimeStamp###########
* Function to parse timestamp of one LRC line
* if successful, return timestamp in milliseconds
* otherwise return -1
* */
qint64 LyricsModel::LyricsModelPrivate::parseOneTimeStamp(
QString::const_iterator &begin,
QString::const_iterator end)
{
/* Example of LRC format and corresponding states
*
* States:
*
* [00:01.02]bla bla
* ^^^ ^^ ^^ ^^
* ||| || || ||
* ||| || || |End
* ||| || || RightBracket
* ||| || |Hundredths
* ||| || Period
* ||| |Seconds
* ||| Colon
* ||Minutes
* |LeftBracket
* Start
* */
enum States {Start, LeftBracket, Minutes, Colon, Seconds, Period, Hundredths, RightBracket, End};
auto states {States::Start};
auto minute {0}, second {0}, hundred {0};
while (begin != end) {
switch (begin->toLatin1()) {
case '.':
if (states == Seconds)
states = Period;
break;
case '[':
if (states == Start)
states = LeftBracket;
break;
case ']':
begin++;
if (states == Hundredths) {
return minute * 60 * 1000 + second * 1000 +
hundred * 10; // we return milliseconds
}
else {
return -1;
}
case ':':
if (states == Minutes)
states = Colon;
break;
default:
if (begin->isDigit()) {
switch (states) {
case LeftBracket:
states = Minutes;
[[fallthrough]];
case Minutes:
minute *= 10;
minute += begin->digitValue();
break;
case Colon:
states = Seconds;
[[fallthrough]];
case Seconds:
second *= 10;
second += begin->digitValue();
break;
case Period:
states = Hundredths;
[[fallthrough]];
case Hundredths:
// we only parse to hundredth second
if (hundred >= 100) {
break;
}
hundred *= 10;
hundred += begin->digitValue();
break;
default:
// lyric format is corrupt
break;
}
} else {
begin++;
return -1;
}
break;
}
begin++;
}
// end of lyric and no correct value found
return -1;
}
QString
LyricsModel::LyricsModelPrivate::parseOneLine(QString::const_iterator &begin,
QString::const_iterator end)
{
auto size{0};
auto it = begin;
while (begin != end) {
if (begin->toLatin1() != '[') {
size++;
} else
break;
begin++;
}
if (size) {
return QString(--it, size); // FIXME: really weird workaround for QChar,
// otherwise first char is lost
} else
return {};
}
/*
* [length:04:07.46]
* [re:www.megalobiz.com/lrc/maker]
* [ve:v1.2.3]
*/
QString LyricsModel::LyricsModelPrivate::parseTags(QString::const_iterator &begin, QString::const_iterator end)
{
static auto skipTillChar = [](QString::const_iterator begin, QString::const_iterator end, char endChar) {
while (begin != end && begin->toLatin1() != endChar) {
begin++;
}
return begin;
};
static std::unordered_map<QString, QString> map = {
{QStringLiteral("ar"), i18n("Artist")},
{QStringLiteral("al"), i18n("Album")},
{QStringLiteral("ti"), i18n("Title")},
{QStringLiteral("au"), i18n("Creator")},
{QStringLiteral("length"), i18n("Length")},
{QStringLiteral("by"), i18nc("as in `Created by: Joe`", "Created by")},
{QStringLiteral("re"), i18n("Editor")},
{QStringLiteral("ve"), i18n("Version")}};
QString tags;
while (begin != end) {
// skip till tags
begin = skipTillChar(begin, end, '[');
if (begin != end) {
begin++;
}
else {
break;
}
auto tagIdEnd = skipTillChar(begin, end, ':');
auto tagId = QString(begin, std::distance(begin, tagIdEnd));
if (tagIdEnd != end &&
(map.count(tagId) || tagId == QStringLiteral("offset"))) {
tagIdEnd++;
auto tagContentEnd = skipTillChar(tagIdEnd, end, ']');
bool ok = true;
if (map.count(tagId)) {
tags += i18nc(
"this is a key => value map", "%1: %2\n", map[tagId],
QString(tagIdEnd, std::distance(tagIdEnd, tagContentEnd)));
} else {
// offset tag
offset = QString(tagIdEnd, std::distance(tagIdEnd, tagContentEnd))
.toLongLong(&ok);
}
if (ok) {
begin = tagContentEnd;
} else {
// Invalid offset tag, we step back one to compensate the '[' we
// step over
begin--;
break;
}
} else {
// No tag, we step back one to compensate the '[' we step over
begin--;
break;
}
}
return tags;
}
bool LyricsModel::LyricsModelPrivate::parse(const QString &lyric)
{
lyrics.clear();
offset = 0;
if (lyric.isEmpty())
return false;
QString::const_iterator begin = lyric.begin(), end = lyric.end();
auto tag = parseTags(begin, end);
std::vector<qint64> timeStamps;
while (begin != lyric.end()) {
auto timeStamp = parseOneTimeStamp(begin, end);
while (timeStamp >= 0) {
// one line can have multiple timestamps
// [00:12.00][00:15.30]Some more lyrics ...
timeStamps.push_back(timeStamp);
timeStamp = parseOneTimeStamp(begin, end);
}
auto string = parseOneLine(begin, end);
if (!string.isEmpty() && !timeStamps.empty()) {
for (auto time : timeStamps) {
lyrics.push_back({string, time});
}
}
timeStamps.clear();
}
std::sort(lyrics.begin(),
lyrics.end(),
[](const std::pair<QString, qint64> &lhs,
const std::pair<QString, qint64> &rhs) {
return lhs.second < rhs.second;
});
if (offset) {
std::transform(lyrics.begin(), lyrics.end(),
lyrics.begin(),
[this](std::pair<QString, qint64> &element) {
element.second = std::max(element.second + offset, 0ll);
return element;
});
}
// insert tags to first lyric front
if (!lyrics.empty() && !tag.isEmpty()) {
lyrics.insert(lyrics.begin(), {tag, 0});
}
return !lyrics.empty();
}
LyricsModel::LyricsModel(QObject *parent)
: QAbstractListModel(parent)
, d(std::make_unique<LyricsModelPrivate>())
{
}
LyricsModel::~LyricsModel() = default;
int LyricsModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return d->lyrics.size();
}
QVariant LyricsModel::data(const QModelIndex &index, int role) const
{
if (index.row() < 0 || index.row() >= (int)d->lyrics.size())
return {};
switch (role) {
case LyricsRole::Lyric:
return d->lyrics.at(index.row()).first;
case LyricsRole::TimeStamp:
return d->lyrics.at(index.row()).second;
}
return QVariant();
}
void LyricsModel::setLyric(const QString &lyric)
{
bool isLRC = true;
beginResetModel();
auto ret = d->parse(lyric);
// has non-LRC formatted lyric
if (!ret && !lyric.isEmpty()) {
d->lyrics = {{lyric, 0ll}};
d->highlightedIndex = -1;
isLRC = false;
}
endResetModel();
Q_EMIT highlightedIndexChanged();
Q_EMIT lyricChanged();
if (isLRC != d->isLRC) {
d->isLRC = isLRC;
Q_EMIT isLRCChanged();
}
}
void LyricsModel::setPosition(qint64 position)
{
if (!isLRC()) {
return;
}
// do binary search
auto result =
std::lower_bound(d->lyrics.begin(),
d->lyrics.end(),
position,
[](const std::pair<QString, qint64> &lhs, qint64 value) {
return lhs.second < value;
});
if (result != d->lyrics.begin()) {
d->highlightedIndex = std::distance(d->lyrics.begin(), --result);
} else {
d->highlightedIndex = -1;
}
Q_EMIT highlightedIndexChanged();
}
int LyricsModel::highlightedIndex() const
{
return d->highlightedIndex;
}
bool LyricsModel::isLRC() const
{
return d->isLRC;
}
QHash<int, QByteArray> LyricsModel::roleNames() const
{
return {{LyricsRole::Lyric, QByteArrayLiteral("lyric")}, {LyricsRole::TimeStamp, QByteArrayLiteral("timestamp")}};
}
/*
SPDX-FileCopyrightText: 2021 Han Young <hanyoung@protonmail.com>
SPDX-License-Identifier: LGPL-3.0-or-later
*/
#ifndef LYRICSMODEL_H
#define LYRICSMODEL_H
#include "elisaLib_export.h"
#include <QAbstractListModel>
#include <memory>
#include <vector>
class ELISALIB_EXPORT LyricsModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int highlightedIndex
READ highlightedIndex
NOTIFY highlightedIndexChanged)
Q_PROPERTY(bool isLRC READ isLRC NOTIFY isLRCChanged)
public:
enum LyricsRole {Lyric = Qt::UserRole, TimeStamp};
LyricsModel(QObject *parent = nullptr);
~LyricsModel() override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int highlightedIndex() const;
bool isLRC() const;
public Q_SLOTS:
Q_INVOKABLE void setLyric(const QString &lyric);
Q_INVOKABLE void setPosition(qint64 position);
Q_SIGNALS:
void lyricChanged();
void highlightedIndexChanged();
void positionChanged();
void isLRCChanged();
private:
class LyricsModelPrivate;
std::unique_ptr<LyricsModelPrivate> d;
};
#endif // LYRICSMODEL_H
......@@ -5,7 +5,7 @@
SPDX-License-Identifier: LGPL-3.0-or-later
*/
import QtQuick 2.10
import QtQuick 2.15
import QtQuick.Window 2.2
import QtQuick.Controls 2.2
import QtQml.Models 2.2
......@@ -31,10 +31,10 @@ Kirigami.Page {
signal openAlbum()
readonly property bool nothingPlaying: albumName.length === 0
&& artistName.length === 0
&& albumArtUrl.toString().length === 0
&& songTitle.length === 0
&& fileUrl.toString().length === 0
&& artistName.length === 0
&& albumArtUrl.toString().length === 0
&& songTitle.length === 0
&& fileUrl.toString().length === 0
title: i18nc("Title of the context view related to the currently playing track", "Now Playing")
padding: 0
......@@ -43,7 +43,7 @@ Kirigami.Page {
TrackContextMetaDataModel {
id: metaDataModel
onLyricsChanged: lyricsModel.setLyric(lyrics)
manager: ElisaApplication.musicManager
}
......@@ -108,8 +108,6 @@ Kirigami.Page {
id: showLyricButton
ButtonGroup.group: nowPlayingButtons
readonly property alias item: lyricScroll
checkable: true
checked: persistentSettings.nowPlayingPreferLyric
display: topItem.isWidescreen ? AbstractButton.TextBesideIcon : AbstractButton.IconOnly
......@@ -170,7 +168,7 @@ Kirigami.Page {
id: contentLayout
property bool wideMode: allMetaDataLoader.width <= width * 0.5
&& allMetaDataLoader.height <= height
&& allMetaDataLoader.height <= height
anchors.fill: parent
visible: !topItem.nothingPlaying
......@@ -249,18 +247,16 @@ Kirigami.Page {
}
}
// Lyric
// Lyrics
ScrollView {
id: lyricScroll
implicitWidth: {
if (contentLayout.wideMode) {
return contentLayout.width * 0.5
} else {
return showLyricButton.checked? contentLayout.width : 0
return showLyricButton.checked ? contentLayout.width : 0
}
}
implicitHeight: Math.min(lyricItem.height, parent.height)
contentWidth: availableWidth
......@@ -268,36 +264,77 @@ Kirigami.Page {
// HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
PropertyAnimation {
id: lyricScrollAnimation
// the target is a flickable
target: lyricScroll.contentItem
property: "contentY"
onToChanged: restart()
}
// Lyrics
Item {
id: lyricItem
property real margins: Kirigami.Units.largeSpacing + lyricScroll.ScrollBar.vertical.width
width: lyricScroll.width - margins
height: lyricLabel.visible? lyricLabel.height : lyricPlaceholder.height
height: lyricsView.count == 0 ? lyricPlaceholder.height : lyricsView.height
x: Kirigami.Units.largeSpacing
Label {
id: lyricLabel
text: metaDataModel.lyrics
textFormat: Text.PlainText
wrapMode: Text.WordWrap
horizontalAlignment: contentLayout.wideMode? Text.AlignLeft : Text.AlignHCenter
// fix binding loop
// this does not affect the alignment
// since we aligned lyricScroll
verticalAlignment: Text.AlignTop
visible: text !== ""
ListView {
id: lyricsView
height: contentHeight
width: parent.width
model: lyricsModel
delegate: Label {
text: lyric
width: lyricItem.width
wrapMode: Text.WordWrap
font.bold: ListView.isCurrentItem
horizontalAlignment: contentLayout.wideMode? Text.AlignLeft : Text.AlignHCenter
MouseArea {
height: parent.height
width: Math.min(parent.width, parent.contentWidth)
x: contentLayout.wideMode? 0 : (parent.width - width) / 2
enabled: lyricsModel.isLRC
cursorShape: enabled ? Qt.PointingHandCursor : undefined
onClicked: {
ElisaApplication.audioPlayer.position = timestamp;
}
}
}
currentIndex: lyricsModel.highlightedIndex
onCurrentIndexChanged: {
if (currentIndex === -1)
return
// center aligned
var toPos = Math.round(currentItem.y + currentItem.height * 0.5 - lyricScroll.height * 0.5)
// make sure the first and the last lines are always
// positioned at the beginning and the end of the view
toPos = Math.max(toPos, 0)
toPos = Math.min(toPos, contentHeight - lyricScroll.height)
lyricScrollAnimation.to = toPos
}
}
LyricsModel {
id: lyricsModel
}
Connections {
target: ElisaApplication.audioPlayer
function onPositionChanged(position) {
lyricsModel.setPosition(position)
}
}
Loader {
id: lyricPlaceholder
anchors.centerIn: parent
width: parent.width
active: !lyricLabel.visible
active: lyricsView.count === 0
visible: active && status === Loader.Ready
sourceComponent: Kirigami.PlaceholderMessage {
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment