Compare commits
1
Commits
main
...
3e65f21fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e65f21fcb |
No files matched your search
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
|
||||
|
||||
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
|
||||
TEST_NAME playlistmodeltest
|
||||
LINK_LIBRARIES Qt6::Qml Qt6::Test
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test
|
||||
)
|
||||
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
|
||||
|
||||
@@ -29,3 +29,8 @@ ecm_add_test(translationstoretest.cpp
|
||||
TEST_NAME translationstoretest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
|
||||
ecm_add_test(mediainfotest.cpp
|
||||
TEST_NAME mediainfotest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "mediainfo.h"
|
||||
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
class MediaInfoTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void cleanTitle_data()
|
||||
{
|
||||
QTest::addColumn<QString>("fileName");
|
||||
QTest::addColumn<QString>("title");
|
||||
|
||||
QTest::newRow("scene episode") << u"VisionQuest.S01E01.No.Strings.DSNP.DV.HDR.WEB-DL.DDP5.1.Atmos.H.265-GRP.mkv"_s
|
||||
<< u"S01E01 · No Strings"_s;
|
||||
QTest::newRow("episode without title") << u"The.Show.S02E10.1080p.WEB.h264-GRP.mkv"_s << u"The Show · S02E10"_s;
|
||||
QTest::newRow("lowercase double episode") << u"show.s01e01e02.pilot.720p.hdtv.mkv"_s << u"S01E01E02 · pilot"_s;
|
||||
QTest::newRow("spaced episode") << u"Mr. Robot - S01E01 - eps1.0_hellofriend.mov.mkv"_s << u"S01E01 · eps1.0 hellofriend.mov"_s;
|
||||
QTest::newRow("movie with year") << u"Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv"_s << u"Dune Part Two (2024)"_s;
|
||||
QTest::newRow("anime release") << u"[SubsPlease] Frieren - 05 (1080p) [A1B2C3D4].mkv"_s << u"Frieren - 05"_s;
|
||||
QTest::newRow("plain name") << u"Holiday 2019 at the beach.mp4"_s << u"Holiday 2019 at the beach"_s;
|
||||
QTest::newRow("music") << u"01 - Intro.flac"_s << u"01 - Intro"_s;
|
||||
QTest::newRow("only tags") << u"1080p.mkv"_s << u"1080p"_s;
|
||||
}
|
||||
|
||||
void cleanTitle()
|
||||
{
|
||||
QFETCH(QString, fileName);
|
||||
QFETCH(QString, title);
|
||||
QCOMPARE(MediaInfo::cleanTitle(fileName), title);
|
||||
}
|
||||
|
||||
void probeDurationReadsTheLength()
|
||||
{
|
||||
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
|
||||
if (ffmpeg.isEmpty()) {
|
||||
QSKIP("ffmpeg is needed to make a sample file");
|
||||
}
|
||||
QTemporaryDir dir;
|
||||
const QString path = dir.filePath(u"tone.mka"_s);
|
||||
QProcess process;
|
||||
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=3"_s, path});
|
||||
QVERIFY(process.waitForFinished());
|
||||
QCOMPARE(process.exitCode(), 0);
|
||||
|
||||
const std::optional<double> seconds = MediaInfo::probeDuration(path);
|
||||
QVERIFY(seconds);
|
||||
QVERIFY2(qAbs(*seconds - 3) < 0.1, qPrintable(QString::number(*seconds)));
|
||||
QVERIFY(!MediaInfo::probeDuration(dir.filePath(u"missing.mkv"_s)));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(MediaInfoTest)
|
||||
|
||||
#include "mediainfotest.moc"
|
||||
@@ -4,7 +4,9 @@
|
||||
#include "playlistmodel.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QSignalSpy>
|
||||
#include <QStandardPaths>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
@@ -98,6 +100,31 @@ private Q_SLOTS:
|
||||
QCOMPARE(model.playingIndex(), -1);
|
||||
QCOMPARE(model.count(), 1);
|
||||
}
|
||||
|
||||
void titleAndDurationRoles()
|
||||
{
|
||||
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
|
||||
if (ffmpeg.isEmpty()) {
|
||||
QSKIP("ffmpeg is needed to make a sample file");
|
||||
}
|
||||
QTemporaryDir dir;
|
||||
const QString path = dir.filePath(u"Show.S01E02.The.Tone.1080p.WEB.mka"_s);
|
||||
QProcess process;
|
||||
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=2"_s, path});
|
||||
QVERIFY(process.waitForFinished());
|
||||
QCOMPARE(process.exitCode(), 0);
|
||||
|
||||
PlaylistModel model;
|
||||
QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
|
||||
model.append({QUrl::fromLocalFile(path), QUrl(u"https://example.com/a"_s)});
|
||||
QCOMPARE(model.data(model.index(0), PlaylistModel::TitleRole).toString(), u"S01E02 · The Tone"_s);
|
||||
QCOMPARE(model.data(model.index(1), PlaylistModel::TitleRole).toString(), u"https://example.com/a"_s);
|
||||
QCOMPARE(model.data(model.index(1), PlaylistModel::DurationRole).toDouble(), -1.0);
|
||||
|
||||
QTRY_COMPARE(changed.count(), 1);
|
||||
QCOMPARE(changed.first().at(2).value<QList<int>>(), QList<int>{PlaylistModel::DurationRole});
|
||||
QVERIFY(qAbs(model.data(model.index(0), PlaylistModel::DurationRole).toDouble() - 2) < 0.1);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(PlaylistModelTest)
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
|
||||
# apart from the app so autotests can link it.
|
||||
add_library(qtpotato_core STATIC
|
||||
media/mediainfo.cpp
|
||||
media/mediainfo.h
|
||||
mpv/mpvuserconfig.cpp
|
||||
mpv/mpvuserconfig.h
|
||||
mpv/trackselection.cpp
|
||||
@@ -12,7 +14,7 @@ add_library(qtpotato_core STATIC
|
||||
translate/translationstore.cpp
|
||||
translate/translationstore.h
|
||||
)
|
||||
target_include_directories(qtpotato_core PUBLIC mpv subtitles translate)
|
||||
target_include_directories(qtpotato_core PUBLIC media mpv subtitles translate)
|
||||
target_link_libraries(qtpotato_core PUBLIC
|
||||
Qt6::Core
|
||||
KF6::I18n
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "mediainfo.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
}
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
// Words that start the release details in scene-style names; everything after them is dropped.
|
||||
bool isReleaseTag(const QString &word)
|
||||
{
|
||||
static const QSet<QString> tags{
|
||||
u"web"_s, u"web-dl"_s, u"webdl"_s, u"webrip"_s, u"bluray"_s, u"blu-ray"_s, u"bdrip"_s, u"brrip"_s, u"hdtv"_s,
|
||||
u"dvdrip"_s, u"hdrip"_s, u"remux"_s, u"uhd"_s, u"dsnp"_s, u"amzn"_s, u"nf"_s, u"hmax"_s, u"atvp"_s,
|
||||
u"hulu"_s, u"pcok"_s, u"dv"_s, u"hdr"_s, u"hdr10"_s, u"hdr10+"_s, u"sdr"_s, u"x264"_s, u"x265"_s,
|
||||
u"h264"_s, u"h265"_s, u"hevc"_s, u"avc"_s, u"aac"_s, u"ac3"_s, u"dts"_s, u"ddp"_s, u"10bit"_s,
|
||||
u"8bit"_s, u"repack"_s, u"proper"_s, u"internal"_s, u"imax"_s, u"multi"_s,
|
||||
};
|
||||
static const QRegularExpression pattern(uR"(^(\d{3,4}[pi]|[248]k|ddp?\d.*|h\d{3}|aac\d.*|dts-.*)$)"_s, QRegularExpression::CaseInsensitiveOption);
|
||||
const QString lower = word.toLower();
|
||||
return tags.contains(lower) || pattern.match(lower).hasMatch();
|
||||
}
|
||||
|
||||
bool isYear(const QString &word)
|
||||
{
|
||||
static const QRegularExpression pattern(uR"(^\(?(19|20)\d\d\)?$)"_s);
|
||||
return pattern.match(word).hasMatch();
|
||||
}
|
||||
|
||||
// The words before the first release tag, joined by spaces.
|
||||
QString untilReleaseTags(const QString &text)
|
||||
{
|
||||
QStringList kept;
|
||||
const QStringList words = text.split(u' ', Qt::SkipEmptyParts);
|
||||
for (const QString &word : words) {
|
||||
if (isReleaseTag(word)) {
|
||||
break;
|
||||
}
|
||||
kept.append(word);
|
||||
}
|
||||
// "Movie 2024" reads better as "Movie (2024)".
|
||||
if (kept.size() > 1 && isYear(kept.last())) {
|
||||
QString year = kept.takeLast();
|
||||
year.remove(u'(').remove(u')');
|
||||
kept.append(u"(%1)"_s.arg(year));
|
||||
}
|
||||
QString result = kept.join(u' ');
|
||||
// Leftover separators like "Show -" when the tags followed a dash.
|
||||
static const QRegularExpression trailing(uR"([\s\-–—:·]+$)"_s);
|
||||
static const QRegularExpression leading(uR"(^[\s\-–—:·]+)"_s);
|
||||
return result.remove(trailing).remove(leading);
|
||||
}
|
||||
}
|
||||
|
||||
namespace MediaInfo
|
||||
{
|
||||
QString cleanTitle(const QString &fileName)
|
||||
{
|
||||
QString base = fileName;
|
||||
static const QRegularExpression extension(uR"(\.[A-Za-z0-9]{2,4}$)"_s);
|
||||
base.remove(extension);
|
||||
const QString fallback = base;
|
||||
|
||||
// "[Group] Name [1080p] (ABCD1234)": bracketed parts are release details.
|
||||
static const QRegularExpression brackets(uR"(\[[^\]]*\]|\{[^}]*\})"_s);
|
||||
base.remove(brackets);
|
||||
// Parentheses too, when they hold release tags or a checksum, but not "(2024)".
|
||||
static const QRegularExpression parens(uR"(\(([^)]*)\))"_s);
|
||||
static const QRegularExpression wordSeparator(uR"([\s,]+)"_s);
|
||||
static const QRegularExpression checksum(uR"(^[0-9A-Fa-f]{8}$)"_s);
|
||||
for (auto it = parens.globalMatch(QString(base)); it.hasNext();) {
|
||||
const auto match = it.next();
|
||||
const QStringList words = match.captured(1).split(wordSeparator, Qt::SkipEmptyParts);
|
||||
if (std::any_of(words.begin(), words.end(), isReleaseTag) || checksum.match(match.captured(1)).hasMatch()) {
|
||||
base.replace(match.captured(0), u" "_s);
|
||||
}
|
||||
}
|
||||
|
||||
// Scene names use dots or underscores between words.
|
||||
base.replace(u'_', u' ');
|
||||
if (!base.trimmed().contains(u' ')) {
|
||||
base.replace(u'.', u' ');
|
||||
}
|
||||
base = base.simplified();
|
||||
|
||||
static const QRegularExpression episode(uR"(\b[Ss](\d{1,2})[ ._-]?[Ee](\d{1,3})(?:-?[Ee]\d{1,3})*\b)"_s);
|
||||
if (const auto match = episode.match(base); match.hasMatch()) {
|
||||
const QString code = match.captured(0).toUpper().remove(u' ').remove(u'.').remove(u'_');
|
||||
const QString episodeTitle = untilReleaseTags(base.mid(match.capturedEnd()));
|
||||
if (!episodeTitle.isEmpty()) {
|
||||
return u"%1 · %2"_s.arg(code, episodeTitle);
|
||||
}
|
||||
const QString show = untilReleaseTags(base.left(match.capturedStart()));
|
||||
return show.isEmpty() ? code : u"%1 · %2"_s.arg(show, code);
|
||||
}
|
||||
|
||||
const QString title = untilReleaseTags(base);
|
||||
return title.isEmpty() ? fallback : title;
|
||||
}
|
||||
|
||||
std::optional<double> probeDuration(const QString &path)
|
||||
{
|
||||
AVFormatContext *context = nullptr;
|
||||
if (avformat_open_input(&context, path.toUtf8().constData(), nullptr, nullptr) < 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
// Most containers store the length in their header; others need a look at the streams.
|
||||
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) {
|
||||
avformat_find_stream_info(context, nullptr);
|
||||
}
|
||||
std::optional<double> seconds;
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0) {
|
||||
seconds = double(context->duration) / AV_TIME_BASE;
|
||||
}
|
||||
avformat_close_input(&context);
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace MediaInfo
|
||||
{
|
||||
// A short title for a media file name, without the release tags: "Show.S01E01.Pilot.1080p.WEB.mkv"
|
||||
// becomes "S01E01 · Pilot" and "Movie.2024.2160p.BluRay.mkv" becomes "Movie (2024)". Returns the
|
||||
// name without its extension when nothing better is found.
|
||||
QString cleanTitle(const QString &fileName);
|
||||
|
||||
// Length of the media file at \a path in seconds, or nothing when it can't be read.
|
||||
std::optional<double> probeDuration(const QString &path);
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
#include "playlistmodel.h"
|
||||
|
||||
#include "mediainfo.h"
|
||||
|
||||
#include <QCollator>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMimeDatabase>
|
||||
#include <QtConcurrentRun>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
|
||||
PlaylistModel::PlaylistModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
// Reading many files at once from one disk is slower than a few at a time.
|
||||
m_probePool.setMaxThreadCount(2);
|
||||
}
|
||||
|
||||
PlaylistModel::~PlaylistModel()
|
||||
{
|
||||
m_probePool.clear();
|
||||
}
|
||||
|
||||
int PlaylistModel::rowCount(const QModelIndex &parent) const
|
||||
@@ -36,6 +46,10 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const
|
||||
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
|
||||
case IsPlayingRole:
|
||||
return index.row() == m_playingIndex;
|
||||
case TitleRole:
|
||||
return url.isLocalFile() ? MediaInfo::cleanTitle(url.fileName()) : url.toDisplayString();
|
||||
case DurationRole:
|
||||
return m_durations.value(url, -1);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
|
||||
{UrlRole, "url"},
|
||||
{NameRole, "name"},
|
||||
{IsPlayingRole, "isPlaying"},
|
||||
{TitleRole, "title"},
|
||||
{DurationRole, "duration"},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
|
||||
m_playingIndex = -1;
|
||||
endResetModel();
|
||||
Q_EMIT countChanged();
|
||||
probeDurations(m_urls);
|
||||
play(std::max<int>(0, int(m_urls.indexOf(url))));
|
||||
}
|
||||
|
||||
@@ -140,6 +157,7 @@ void PlaylistModel::append(const QList<QUrl> &urls)
|
||||
m_urls.append(added);
|
||||
endInsertRows();
|
||||
Q_EMIT countChanged();
|
||||
probeDurations(added);
|
||||
}
|
||||
|
||||
void PlaylistModel::replace(const QList<QUrl> &urls)
|
||||
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
|
||||
m_playingIndex = -1;
|
||||
endResetModel();
|
||||
Q_EMIT countChanged();
|
||||
probeDurations(m_urls);
|
||||
play(0);
|
||||
}
|
||||
|
||||
@@ -274,3 +293,25 @@ void PlaylistModel::setPlayingIndex(int index)
|
||||
}
|
||||
Q_EMIT playingIndexChanged();
|
||||
}
|
||||
|
||||
void PlaylistModel::probeDurations(const QList<QUrl> &urls)
|
||||
{
|
||||
for (const QUrl &url : urls) {
|
||||
// Streams are skipped: opening them can take long and costs bandwidth.
|
||||
if (!url.isLocalFile() || m_durations.contains(url)) {
|
||||
continue;
|
||||
}
|
||||
m_durations.insert(url, -1);
|
||||
QtConcurrent::run(&m_probePool, MediaInfo::probeDuration, url.toLocalFile()).then(this, [this, url](std::optional<double> seconds) {
|
||||
if (!seconds) {
|
||||
return;
|
||||
}
|
||||
m_durations.insert(url, *seconds);
|
||||
for (int row = 0; row < m_urls.size(); ++row) {
|
||||
if (m_urls.at(row) == url) {
|
||||
Q_EMIT dataChanged(index(row), index(row), {DurationRole});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QThreadPool>
|
||||
#include <QUrl>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
@@ -24,10 +26,15 @@ public:
|
||||
UrlRole = Qt::UserRole + 1,
|
||||
NameRole,
|
||||
IsPlayingRole,
|
||||
// Short name without release tags; see MediaInfo::cleanTitle().
|
||||
TitleRole,
|
||||
// Length in seconds, or -1 while unknown. Local files are read in the background.
|
||||
DurationRole,
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit PlaylistModel(QObject *parent = nullptr);
|
||||
~PlaylistModel() override;
|
||||
|
||||
int rowCount(const QModelIndex &parent = {}) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
@@ -66,7 +73,12 @@ Q_SIGNALS:
|
||||
private:
|
||||
QList<QUrl> expand(const QList<QUrl> &urls) const;
|
||||
void setPlayingIndex(int index);
|
||||
void probeDurations(const QList<QUrl> &urls);
|
||||
|
||||
QList<QUrl> m_urls;
|
||||
int m_playingIndex = -1;
|
||||
// Known lengths in seconds; -1 for files that couldn't be read or are still being read.
|
||||
QHash<QUrl, double> m_durations;
|
||||
// Declared last so it is destroyed first and waits for running probes.
|
||||
QThreadPool m_probePool;
|
||||
};
|
||||
+63
-47
@@ -8,20 +8,23 @@ import QtQuick.Layouts
|
||||
import org.kde.ki18n
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
// One row of the playlist: row number, playing icon and file name.
|
||||
// One compact row of the playlist: row number (or playing icon), short title, length and,
|
||||
// on hover or focus, a remove button.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property int index
|
||||
required property url url
|
||||
required property string name
|
||||
required property string title
|
||||
required property real duration
|
||||
required property bool isPlaying
|
||||
required property PlaylistModel playlist
|
||||
required property MpvItem mpv
|
||||
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
|
||||
|
||||
readonly property bool hovered: hoverHandler.hovered
|
||||
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
|
||||
readonly property bool highlighted: root.isPlaying && !root.hovered
|
||||
activeFocusOnTab: true
|
||||
|
||||
function activate() {
|
||||
@@ -32,6 +35,18 @@ Item {
|
||||
contextMenu.popup()
|
||||
}
|
||||
|
||||
// "1:02:03" or "4:05".
|
||||
function formatDuration(seconds: real): string {
|
||||
if (seconds < 0) {
|
||||
return ""
|
||||
}
|
||||
const total = Math.round(seconds)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const secs = String(total % 60).padStart(2, "0")
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: root.activate()
|
||||
Keys.onEnterPressed: root.activate()
|
||||
Keys.onMenuPressed: root.showContextMenu()
|
||||
@@ -42,15 +57,8 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function labelColor(): color {
|
||||
if (root.isPlaying && !root.hovered) {
|
||||
return Kirigami.Theme.highlightedTextColor
|
||||
}
|
||||
return Kirigami.Theme.textColor
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
implicitHeight: fontSize * 3
|
||||
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
|
||||
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
@@ -71,7 +79,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
||||
color: {
|
||||
if (root.isPlaying && !root.hovered) {
|
||||
if (root.highlighted) {
|
||||
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
|
||||
}
|
||||
if (root.hovered) {
|
||||
@@ -85,67 +93,75 @@ Item {
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Kirigami.Units.largeSpacing
|
||||
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
// Same width on every row, so the titles line up.
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
|
||||
Kirigami.Units.iconSizes.small)
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: root.index + 1
|
||||
color: root.labelColor()
|
||||
visible: QtPotatoSettings.playlistShowRowNumber
|
||||
font.pointSize: root.fontSize
|
||||
font.features: { "tnum": 1 }
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||
Layout.preferredWidth: rowNumberMetrics.width
|
||||
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
|
||||
Kirigami.Icon {
|
||||
anchors.centerIn: parent
|
||||
width: Kirigami.Units.iconSizes.small
|
||||
height: Kirigami.Units.iconSizes.small
|
||||
source: "media-playback-start"
|
||||
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
|
||||
visible: root.isPlaying
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: rowNumberMetrics
|
||||
font.pointSize: root.fontSize
|
||||
font: Kirigami.Theme.smallFont
|
||||
text: String(root.playlist.count).replace(/./g, "0")
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
|
||||
visible: QtPotatoSettings.playlistShowRowNumber
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillHeight: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
||||
}
|
||||
|
||||
Kirigami.Icon {
|
||||
source: "media-playback-start"
|
||||
color: root.labelColor()
|
||||
visible: root.isPlaying
|
||||
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
||||
}
|
||||
|
||||
LabelWithTooltip {
|
||||
text: root.name
|
||||
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
|
||||
color: root.labelColor()
|
||||
id: titleLabel
|
||||
text: root.title
|
||||
toolTipText: root.name
|
||||
alwaysShowToolTip: root.title !== root.name
|
||||
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
|
||||
elide: Text.ElideRight
|
||||
font.pointSize: root.fontSize
|
||||
font.bold: root.isPlaying
|
||||
maximumLineCount: 1
|
||||
verticalAlignment: Qt.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0
|
||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||
}
|
||||
|
||||
Button {
|
||||
Label {
|
||||
text: root.formatDuration(root.duration)
|
||||
visible: text !== ""
|
||||
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: removeButton
|
||||
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
|
||||
icon.name: "list-remove"
|
||||
icon.width: Kirigami.Units.iconSizes.small
|
||||
icon.height: Kirigami.Units.iconSizes.small
|
||||
display: AbstractButton.IconOnly
|
||||
flat: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
visible: root.hovered || root.activeFocus || activeFocus
|
||||
padding: 0
|
||||
Accessible.name: text
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
|
||||
Layout.rightMargin: Kirigami.Units.smallSpacing
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: height
|
||||
onClicked: root.playlist.remove(root.index)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user