feat: load the user's mpv.conf

This commit is contained in:
tri committed 2026-10-28 00:03:01 +07:00
1 parent fe2f159660
commit 6b5742b2ca
11 files changed
+298 -6

No files matched your search

+6 -1
View File
@@ -1,4 +1,9 @@
ecm_add_test(subtitleparsertest.cpp
TEST_NAME subtitleparsertest
LINK_LIBRARIES qtpotato_subtitles Qt6::Test
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(mpvuserconfigtest.cpp
TEST_NAME mpvuserconfigtest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
+39
View File
@@ -0,0 +1,39 @@
#include "mpvuserconfig.h"
#include <QTest>
using namespace Qt::Literals::StringLiterals;
class MpvUserConfigTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void expandsConfigDirPaths()
{
const auto prepared = MpvUserConfig::prepare(u"glsl-shaders=\"~~/shaders/a.glsl\"\ninclude=~~home/extra.conf\n"_s, u"/home/me/.config/mpv"_s);
QCOMPARE(prepared.content, u"glsl-shaders=\"/home/me/.config/mpv/shaders/a.glsl\"\ninclude=/home/me/.config/mpv/extra.conf\n"_s);
QVERIFY(prepared.skippedOptions.isEmpty());
}
void skipsOptionsThatBreakEmbedding()
{
const QString conf = u"# comment\nvo=gpu-next\nhwdec=auto\n[big]\nno-fs\n--idle=no\n vo = gpu\nvolume=50\n"_s;
const auto prepared = MpvUserConfig::prepare(conf, u"/c"_s);
QCOMPARE(prepared.skippedOptions, (QStringList{u"vo"_s, u"fs"_s, u"idle"_s}));
QCOMPARE(prepared.content,
u"# comment\n# skipped by QtPotato: vo=gpu-next\nhwdec=auto\n[big]\n# skipped by QtPotato: no-fs\n"
"# skipped by QtPotato: --idle=no\n# skipped by QtPotato: vo = gpu\nvolume=50\n"_s);
}
void keepsSimilarlyNamedOptions()
{
// "vo" must not catch "volume" or "vo-..." style options.
const auto prepared = MpvUserConfig::prepare(u"volume=70\nvideo-sync=display-resample\nfs-screen=1\n"_s, u"/c"_s);
QVERIFY(prepared.skippedOptions.isEmpty());
}
};
QTEST_GUILESS_MAIN(MpvUserConfigTest)
#include "mpvuserconfigtest.moc"
+8 -5
View File
@@ -1,12 +1,15 @@
# Subtitle parsing and extraction, kept apart from the app so autotests can link it.
add_library(qtpotato_subtitles STATIC
# Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests
# can link it.
add_library(qtpotato_core STATIC
mpv/mpvuserconfig.cpp
mpv/mpvuserconfig.h
subtitles/subtitleparser.cpp
subtitles/subtitleparser.h
subtitles/subtitleextractor.cpp
subtitles/subtitleextractor.h
)
target_include_directories(qtpotato_subtitles PUBLIC subtitles)
target_link_libraries(qtpotato_subtitles PUBLIC
target_include_directories(qtpotato_core PUBLIC mpv subtitles)
target_link_libraries(qtpotato_core PUBLIC
Qt6::Core
KF6::I18n
PRIVATE
@@ -52,7 +55,7 @@ target_include_directories(qtpotato PRIVATE mpv translate)
target_compile_definitions(qtpotato PRIVATE QTPOTATO_VERSION_STRING="${PROJECT_VERSION}")
target_link_libraries(qtpotato PRIVATE
qtpotato_subtitles
qtpotato_core
Qt6::Gui
Qt6::Qml
Qt6::Quick
+68
View File
@@ -1,7 +1,14 @@
#include "mpvitem.h"
#include "mpvuserconfig.h"
#include <MpvController>
#include <QDir>
#include <QFile>
#include <QSaveFile>
#include <QStandardPaths>
#include <cmath>
#include <utility>
@@ -58,6 +65,8 @@ MpvItem::MpvItem(QQuickItem *parent)
// time; a file loaded before that plays without video. Hold it until ready() fires.
connect(this, &MpvAbstractItem::ready, this, [this] {
m_ready = true;
// Before the first file, so its options apply from the start.
applyUserConfig();
if (m_pendingUrl.isValid()) {
loadFile(std::exchange(m_pendingUrl, {}));
}
@@ -208,6 +217,65 @@ void MpvItem::showText(const QString &text)
commandAsync(QStringList{QStringLiteral("show-text"), text});
}
bool MpvItem::loadUserConfig() const
{
return m_loadUserConfig;
}
void MpvItem::setLoadUserConfig(bool load)
{
if (load != m_loadUserConfig) {
m_loadUserConfig = load;
Q_EMIT loadUserConfigChanged();
}
}
QString MpvItem::userConfigDir() const
{
return MpvUserConfig::configDir();
}
QStringList MpvItem::loadedUserConfigFiles() const
{
return m_loadedUserConfigFiles;
}
QStringList MpvItem::skippedUserOptions() const
{
return m_skippedUserOptions;
}
void MpvItem::applyUserConfig()
{
if (m_loadUserConfig) {
const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/mpv");
QDir().mkpath(cacheDir);
const QStringList files = MpvUserConfig::configFiles();
for (qsizetype i = 0; i < files.size(); ++i) {
QFile source(files.at(i));
if (!source.open(QIODevice::ReadOnly)) {
continue;
}
const MpvUserConfig::Prepared prepared = MpvUserConfig::prepare(QString::fromUtf8(source.readAll()), MpvUserConfig::configDir());
// mpv only loads config from files, so the adapted copy goes to the cache.
QSaveFile copy(QStringLiteral("%1/mpv-%2.conf").arg(cacheDir).arg(i));
if (!copy.open(QIODevice::WriteOnly) || copy.write(prepared.content.toUtf8()) < 0 || !copy.commit()) {
continue;
}
commandAsync(QStringList{QStringLiteral("load-config-file"), copy.fileName()});
m_loadedUserConfigFiles.append(files.at(i));
for (const QString &option : prepared.skippedOptions) {
if (!m_skippedUserOptions.contains(option)) {
m_skippedUserOptions.append(option);
}
}
}
}
Q_EMIT userConfigApplied();
}
void MpvItem::onPropertyChanged(const QString &property, const QVariant &value)
{
if (property == u"media-title") {
+19
View File
@@ -29,6 +29,13 @@ class MpvItem : public MpvAbstractItem
// In seconds; applied to both subtitles so a translation stays in sync with its source.
Q_PROPERTY(double subtitleDelay READ subtitleDelay WRITE setSubtitleDelay NOTIFY subtitleDelayChanged)
// Load the user's mpv.conf when mpv starts. Must be set before the item is shown.
Q_PROPERTY(bool loadUserConfig READ loadUserConfig WRITE setLoadUserConfig NOTIFY loadUserConfigChanged)
Q_PROPERTY(QString userConfigDir READ userConfigDir CONSTANT)
// The mpv.conf files that were loaded, and the options in them that were ignored.
Q_PROPERTY(QStringList loadedUserConfigFiles READ loadedUserConfigFiles NOTIFY userConfigApplied)
Q_PROPERTY(QStringList skippedUserOptions READ skippedUserOptions NOTIFY userConfigApplied)
public:
explicit MpvItem(QQuickItem *parent = nullptr);
~MpvItem() override = default;
@@ -48,6 +55,11 @@ public:
void setSecondarySubtitleId(int id);
double subtitleDelay() const;
void setSubtitleDelay(double seconds);
bool loadUserConfig() const;
void setLoadUserConfig(bool load);
QString userConfigDir() const;
QStringList loadedUserConfigFiles() const;
QStringList skippedUserOptions() const;
Q_INVOKABLE void loadFile(const QUrl &url);
Q_INVOKABLE void togglePause();
@@ -69,11 +81,15 @@ Q_SIGNALS:
void fileLoadedChanged();
void subtitleTracksChanged();
void subtitleDelayChanged();
void loadUserConfigChanged();
// mpv.conf was queued for loading; options set after this take precedence over it.
void userConfigApplied();
private:
void onPropertyChanged(const QString &property, const QVariant &value);
void setFileLoaded(bool loaded);
void updateTracks(const QVariantList &trackList);
void applyUserConfig();
bool m_ready = false;
QUrl m_pendingUrl;
@@ -89,4 +105,7 @@ private:
double m_subtitleDelay = 0.0;
// File added by showSecondarySubtitleFile() that should become secondary once mpv lists it.
QString m_pendingSecondaryPath;
bool m_loadUserConfig = false;
QStringList m_loadedUserConfigFiles;
QStringList m_skippedUserOptions;
};
+95
View File
@@ -0,0 +1,95 @@
#include "mpvuserconfig.h"
#include <QDir>
#include <QFileInfo>
#include <QStandardPaths>
using namespace Qt::Literals::StringLiterals;
namespace
{
// vo must stay libmpv to render inside the window; idle=no would shut mpv down after
// a file ends; the rest belong to a standalone mpv window.
const QStringList SkippedOptions{
u"vo"_s,
u"idle"_s,
u"wid"_s,
u"fs"_s,
u"fullscreen"_s,
u"force-window"_s,
u"gpu-api"_s,
u"gpu-context"_s,
u"config"_s,
u"config-dir"_s,
u"player-operation-mode"_s,
};
// "--no-fs=yes" -> "fs"
QString optionName(QStringView line)
{
QStringView name = line;
if (name.startsWith(u"--")) {
name = name.mid(2);
}
const qsizetype end = name.indexOf(u'=');
if (end >= 0) {
name = name.left(end);
}
name = name.trimmed();
if (name.startsWith(u"no-")) {
name = name.mid(3);
}
return name.toString();
}
}
namespace MpvUserConfig
{
QString configDir()
{
if (const QString home = qEnvironmentVariable("MPV_HOME"); !home.isEmpty()) {
return QDir::cleanPath(home);
}
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + u"/mpv"_s;
}
QStringList configFiles()
{
QStringList files;
for (const QString &path : QStringList{u"/etc/mpv/mpv.conf"_s, configDir() + u"/mpv.conf"_s}) {
if (QFileInfo(path).isFile()) {
files.append(path);
}
}
return files;
}
Prepared prepare(const QString &content, const QString &configDir)
{
Prepared result;
QStringList lines;
const QString dir = configDir.endsWith(u'/') ? configDir : configDir + u'/';
for (QString line : QString(content).remove(u'\r').split(u'\n')) {
const QString trimmed = line.trimmed();
if (trimmed.isEmpty() || trimmed.startsWith(u'#') || trimmed.startsWith(u'[')) {
lines.append(line);
continue;
}
const QString name = optionName(trimmed);
if (SkippedOptions.contains(name)) {
if (!result.skippedOptions.contains(name)) {
result.skippedOptions.append(name);
}
lines.append(u"# skipped by QtPotato: "_s + line);
continue;
}
line.replace(u"~~home/"_s, dir).replace(u"~~/"_s, dir);
lines.append(line);
}
result.content = lines.join(u'\n');
return result;
}
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <QStringList>
// The user's mpv.conf, adapted for libmpv. MpvQt initializes mpv with config loading
// off, so the files are loaded afterwards with "load-config-file". In that mode mpv
// can't resolve "~~/" paths, and a few options would break the embedded player.
namespace MpvUserConfig
{
// $MPV_HOME, else $XDG_CONFIG_HOME/mpv, else ~/.config/mpv.
QString configDir();
// Existing mpv.conf files in the order mpv reads them: system-wide, then the user's.
QStringList configFiles();
struct Prepared {
QString content;
// Option names that were dropped, in file order without duplicates.
QStringList skippedOptions;
};
// Expands "~~/" and "~~home/" to \a configDir and drops options that would take the
// video out of the window or stop the player.
Prepared prepare(const QString &content, const QString &configDir);
}
+1
View File
@@ -33,6 +33,7 @@ Kirigami.ApplicationWindow {
id: settingsPage
SettingsPage {
translator: player.translator
mpvItem: player.mpvItem
}
}
+4
View File
@@ -14,6 +14,7 @@ Kirigami.Page {
property bool fullScreen: false
readonly property alias mediaTitle: mpv.mediaTitle
readonly property alias translator: translator
readonly property MpvItem mpvItem: mpv
signal aboutRequested()
signal settingsRequested()
@@ -175,6 +176,9 @@ Kirigami.Page {
MpvItem {
id: mpv
loadUserConfig: QtPotatoSettings.loadMpvConfig
// QtPotato's own subtitle settings win over mpv.conf.
onUserConfigApplied: page.applySubtitleStyle()
anchors {
left: parent.left
right: parent.right
+27
View File
@@ -9,6 +9,7 @@ FormCard.FormCardPage {
id: page
required property TranslationService translator
required property MpvItem mpvItem
title: KI18n.i18nc("@title", "Settings")
@@ -27,6 +28,32 @@ FormCard.FormCardPage {
onVisibleChanged: if (!visible) page.translator.clearError()
}
FormCard.FormHeader {
title: KI18n.i18nc("@title:group", "Player")
}
FormCard.FormCard {
FormCard.FormSwitchDelegate {
text: KI18n.i18nc("@option:check", "Use mpv.conf")
description: KI18n.i18nc("@info", "Reads mpv.conf from %1, like the mpv player does. Changes apply the next time QtPotato starts. Subtitle text size and outline set below take precedence.", page.mpvItem.userConfigDir)
checked: QtPotatoSettings.loadMpvConfig
onToggled: QtPotatoSettings.loadMpvConfig = checked
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextDelegate {
text: page.mpvItem.loadedUserConfigFiles.length > 0
? KI18n.i18nc("@info", "Loaded: %1", page.mpvItem.loadedUserConfigFiles.join(", "))
: (QtPotatoSettings.loadMpvConfig
? KI18n.i18nc("@info", "No mpv.conf found")
: KI18n.i18nc("@info", "mpv.conf is not used"))
description: page.mpvItem.skippedUserOptions.length > 0
? KI18n.i18nc("@info", "Ignored because they would break the built-in player: %1", page.mpvItem.skippedUserOptions.join(", "))
: ""
}
}
FormCard.FormHeader {
title: KI18n.i18nc("@title:group", "Subtitles")
}
+6
View File
@@ -3,6 +3,12 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name="qtpotatorc"/>
<group name="Player">
<entry name="LoadMpvConfig" type="Bool">
<label>Load the user's mpv.conf when the player starts</label>
<default>true</default>
</entry>
</group>
<group name="Subtitles">
<entry name="SubtitleFontSize" type="Int">
<label>Font size of plain-text and translated subtitles, in mpv's scaled pixels (mpv's own default is 38)</label>