diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index c050a3e..8dee0f9 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -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 ) diff --git a/autotests/mpvuserconfigtest.cpp b/autotests/mpvuserconfigtest.cpp new file mode 100644 index 0000000..9335596 --- /dev/null +++ b/autotests/mpvuserconfigtest.cpp @@ -0,0 +1,39 @@ +#include "mpvuserconfig.h" + +#include + +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" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3c1fd87..175a09b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index f0857d9..ef325ae 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -1,7 +1,14 @@ #include "mpvitem.h" +#include "mpvuserconfig.h" + #include +#include +#include +#include +#include + #include #include @@ -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") { diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index 7129285..6c6891e 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -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; }; diff --git a/src/mpv/mpvuserconfig.cpp b/src/mpv/mpvuserconfig.cpp new file mode 100644 index 0000000..19a29ff --- /dev/null +++ b/src/mpv/mpvuserconfig.cpp @@ -0,0 +1,95 @@ +#include "mpvuserconfig.h" + +#include +#include +#include + +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; +} +} diff --git a/src/mpv/mpvuserconfig.h b/src/mpv/mpvuserconfig.h new file mode 100644 index 0000000..a405e1b --- /dev/null +++ b/src/mpv/mpvuserconfig.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +// 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); +} diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 19bb0a5..334e199 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -33,6 +33,7 @@ Kirigami.ApplicationWindow { id: settingsPage SettingsPage { translator: player.translator + mpvItem: player.mpvItem } } diff --git a/src/qml/PlayerPage.qml b/src/qml/PlayerPage.qml index 00ccb5b..3cae395 100644 --- a/src/qml/PlayerPage.qml +++ b/src/qml/PlayerPage.qml @@ -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 diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index c8593f1..d1e2b01 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -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") } diff --git a/src/settings/qtpotatosettings.kcfg b/src/settings/qtpotatosettings.kcfg index 0695c94..e8f4729 100644 --- a/src/settings/qtpotatosettings.kcfg +++ b/src/settings/qtpotatosettings.kcfg @@ -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"> + + + + true + +