feat(playlist): compact rows with clean titles and durations

This commit is contained in:
tri committed 2026-11-13 21:57:34 +07:00
1 parent b34a01a92f
commit 76ba7b0be0
9 files changed
+370 -55

No files matched your search

+6 -1
View File
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest 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) target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
@@ -24,3 +24,8 @@ ecm_add_test(trackselectiontest.cpp
TEST_NAME trackselectiontest TEST_NAME trackselectiontest
LINK_LIBRARIES qtpotato_core Qt6::Test LINK_LIBRARIES qtpotato_core Qt6::Test
) )
ecm_add_test(mediainfotest.cpp
TEST_NAME mediainfotest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
+64
View File
@@ -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"
+27
View File
@@ -4,7 +4,9 @@
#include "playlistmodel.h" #include "playlistmodel.h"
#include <QFile> #include <QFile>
#include <QProcess>
#include <QSignalSpy> #include <QSignalSpy>
#include <QStandardPaths>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QTest> #include <QTest>
@@ -98,6 +100,31 @@ private Q_SLOTS:
QCOMPARE(model.playingIndex(), -1); QCOMPARE(model.playingIndex(), -1);
QCOMPARE(model.count(), 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) QTEST_GUILESS_MAIN(PlaylistModelTest)
+3 -1
View File
@@ -1,6 +1,8 @@
# Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests # Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests
# can link it. # can link it.
add_library(qtpotato_core STATIC add_library(qtpotato_core STATIC
media/mediainfo.cpp
media/mediainfo.h
mpv/mpvuserconfig.cpp mpv/mpvuserconfig.cpp
mpv/mpvuserconfig.h mpv/mpvuserconfig.h
mpv/trackselection.cpp mpv/trackselection.cpp
@@ -10,7 +12,7 @@ add_library(qtpotato_core STATIC
subtitles/subtitleextractor.cpp subtitles/subtitleextractor.cpp
subtitles/subtitleextractor.h subtitles/subtitleextractor.h
) )
target_include_directories(qtpotato_core PUBLIC mpv subtitles) target_include_directories(qtpotato_core PUBLIC media mpv subtitles)
target_link_libraries(qtpotato_core PUBLIC target_link_libraries(qtpotato_core PUBLIC
Qt6::Core Qt6::Core
KF6::I18n KF6::I18n
+129
View File
@@ -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;
}
}
+19
View File
@@ -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);
}
+41
View File
@@ -3,10 +3,13 @@
#include "playlistmodel.h" #include "playlistmodel.h"
#include "mediainfo.h"
#include <QCollator> #include <QCollator>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QMimeDatabase> #include <QMimeDatabase>
#include <QtConcurrentRun>
#include <algorithm> #include <algorithm>
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
PlaylistModel::PlaylistModel(QObject *parent) PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(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 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(); return url.isLocalFile() ? url.fileName() : url.toDisplayString();
case IsPlayingRole: case IsPlayingRole:
return index.row() == m_playingIndex; 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 {}; return {};
} }
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
{UrlRole, "url"}, {UrlRole, "url"},
{NameRole, "name"}, {NameRole, "name"},
{IsPlayingRole, "isPlaying"}, {IsPlayingRole, "isPlaying"},
{TitleRole, "title"},
{DurationRole, "duration"},
}; };
} }
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
m_playingIndex = -1; m_playingIndex = -1;
endResetModel(); endResetModel();
Q_EMIT countChanged(); Q_EMIT countChanged();
probeDurations(m_urls);
play(std::max<int>(0, int(m_urls.indexOf(url)))); 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); m_urls.append(added);
endInsertRows(); endInsertRows();
Q_EMIT countChanged(); Q_EMIT countChanged();
probeDurations(added);
} }
void PlaylistModel::replace(const QList<QUrl> &urls) void PlaylistModel::replace(const QList<QUrl> &urls)
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
m_playingIndex = -1; m_playingIndex = -1;
endResetModel(); endResetModel();
Q_EMIT countChanged(); Q_EMIT countChanged();
probeDurations(m_urls);
play(0); play(0);
} }
@@ -274,3 +293,25 @@ void PlaylistModel::setPlayingIndex(int index)
} }
Q_EMIT playingIndexChanged(); 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});
}
}
});
}
}
+12
View File
@@ -4,7 +4,9 @@
#pragma once #pragma once
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash>
#include <QList> #include <QList>
#include <QThreadPool>
#include <QUrl> #include <QUrl>
#include <QtQml/qqmlregistration.h> #include <QtQml/qqmlregistration.h>
@@ -24,10 +26,15 @@ public:
UrlRole = Qt::UserRole + 1, UrlRole = Qt::UserRole + 1,
NameRole, NameRole,
IsPlayingRole, 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) Q_ENUM(Roles)
explicit PlaylistModel(QObject *parent = nullptr); explicit PlaylistModel(QObject *parent = nullptr);
~PlaylistModel() override;
int rowCount(const QModelIndex &parent = {}) const override; int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override; QVariant data(const QModelIndex &index, int role) const override;
@@ -66,7 +73,12 @@ Q_SIGNALS:
private: private:
QList<QUrl> expand(const QList<QUrl> &urls) const; QList<QUrl> expand(const QList<QUrl> &urls) const;
void setPlayingIndex(int index); void setPlayingIndex(int index);
void probeDurations(const QList<QUrl> &urls);
QList<QUrl> m_urls; QList<QUrl> m_urls;
int m_playingIndex = -1; 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;
}; };
+69 -53
View File
@@ -8,20 +8,23 @@ import QtQuick.Layouts
import org.kde.ki18n import org.kde.ki18n
import org.kde.kirigami as Kirigami 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 { Item {
id: root id: root
required property int index required property int index
required property url url required property url url
required property string name required property string name
required property string title
required property real duration
required property bool isPlaying required property bool isPlaying
required property PlaylistModel playlist required property PlaylistModel playlist
required property MpvItem mpv required property MpvItem mpv
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1 property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
readonly property bool hovered: hoverHandler.hovered readonly property bool hovered: hoverHandler.hovered
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize readonly property bool highlighted: root.isPlaying && !root.hovered
activeFocusOnTab: true activeFocusOnTab: true
function activate() { function activate() {
@@ -32,6 +35,18 @@ Item {
contextMenu.popup() 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.onReturnPressed: root.activate()
Keys.onEnterPressed: root.activate() Keys.onEnterPressed: root.activate()
Keys.onMenuPressed: root.showContextMenu() 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 implicitWidth: ListView.view.width
implicitHeight: fontSize * 3 implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
HoverHandler { HoverHandler {
id: hoverHandler id: hoverHandler
@@ -71,7 +79,7 @@ Item {
anchors.fill: parent anchors.fill: parent
Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.colorSet: Kirigami.Theme.View
color: { color: {
if (root.isPlaying && !root.hovered) { if (root.highlighted) {
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha) return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
} }
if (root.hovered) { if (root.hovered) {
@@ -85,67 +93,75 @@ Item {
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: Kirigami.Units.largeSpacing
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
Label { // Same width on every row, so the titles line up.
text: root.index + 1 Item {
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
}
TextMetrics {
id: rowNumberMetrics
font.pointSize: root.fontSize
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.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Layout.bottomMargin: Kirigami.Units.smallSpacing Kirigami.Units.iconSizes.small)
}
Kirigami.Icon { Label {
source: "media-playback-start" anchors.centerIn: parent
color: root.labelColor() text: root.index + 1
visible: root.isPlaying visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing color: Kirigami.Theme.disabledTextColor
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium font: Kirigami.Theme.smallFont
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium }
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: Kirigami.Theme.smallFont
text: String(root.playlist.count).replace(/./g, "0")
}
} }
LabelWithTooltip { LabelWithTooltip {
text: root.name id: titleLabel
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url text: root.title
color: root.labelColor() toolTipText: root.name
alwaysShowToolTip: root.title !== root.name
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
elide: Text.ElideRight elide: Text.ElideRight
font.pointSize: root.fontSize maximumLineCount: 1
font.bold: root.isPlaying
verticalAlignment: Qt.AlignVCenter verticalAlignment: Qt.AlignVCenter
Layout.fillWidth: true 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") text: KI18n.i18nc("@action:button remove playlist item", "Remove")
icon.name: "list-remove" icon.name: "list-remove"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: AbstractButton.IconOnly display: AbstractButton.IconOnly
flat: true focusPolicy: Qt.TabFocus
visible: root.hovered || root.activeFocus || activeFocus
padding: 0
Accessible.name: text Accessible.name: text
ToolTip.text: text ToolTip.text: text
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay ToolTip.delay: Kirigami.Units.toolTipDelay
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2 Layout.fillHeight: true
Layout.rightMargin: Kirigami.Units.smallSpacing Layout.preferredWidth: height
onClicked: root.playlist.remove(root.index) onClicked: root.playlist.remove(root.index)
} }
} }