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

This commit is contained in:
tri committed 2026-11-13 22:52:29 +07:00
1 parent 76b12d6632
commit 3e65f21fcb
9 files changed
+370 -55

No files matched your search

+3 -1
View File
@@ -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
+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 "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});
}
}
});
}
}
+12
View File
@@ -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;
};
+69 -53
View File
@@ -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
Label {
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
}
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
// Same width on every row, so the titles line up.
Item {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Kirigami.Units.iconSizes.small)
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
Label {
anchors.centerIn: parent
text: root.index + 1
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: Kirigami.Theme.smallFont
text: String(root.playlist.count).replace(/./g, "0")
}
}
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)
}
}