feat: load the user's mpv.conf
This commit is contained in:
1 parent
fe2f159660
commit
6b5742b2ca
11 files changed
+298
-6
No files matched your search
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ Kirigami.ApplicationWindow {
|
||||
id: settingsPage
|
||||
SettingsPage {
|
||||
translator: player.translator
|
||||
mpvItem: player.mpvItem
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user