From 1eb67b829cfe1438e3a09750ed5f86d4a878cdfa Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Tue, 27 Oct 2026 22:07:55 +0700 Subject: [PATCH] feat: add Kirigami app skeleton with libmpv playback Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + CMakeLists.txt | 31 ++++++++ src/CMakeLists.txt | 32 ++++++++ src/main.cpp | 72 +++++++++++++++++ src/mpv/mpvitem.cpp | 140 +++++++++++++++++++++++++++++++++ src/mpv/mpvitem.h | 58 ++++++++++++++ src/qml/Main.qml | 32 ++++++++ src/qml/PlayerPage.qml | 171 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 540 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 src/CMakeLists.txt create mode 100644 src/main.cpp create mode 100644 src/mpv/mpvitem.cpp create mode 100644 src/mpv/mpvitem.h create mode 100644 src/qml/Main.qml create mode 100644 src/qml/PlayerPage.qml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ae9f0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.cache/ +compile_commands.json +*.user diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4508657 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.25) + +project(qtpotato VERSION 0.1.0 LANGUAGES CXX) + +set(QT_MIN_VERSION "6.8.0") +set(KF_MIN_VERSION "6.10.0") + +find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) + +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) +include(ECMQmlModule) +include(FeatureSummary) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Widgets) +find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS Kirigami I18n CoreAddons Crash) +find_package(MpvQt 1.0 REQUIRED) + +find_package(KF6KirigamiAddons 1.0) +set_package_properties(KF6KirigamiAddons PROPERTIES + TYPE RUNTIME + PURPOSE "FormCard About page") + +add_subdirectory(src) + +feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..71fcfb4 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,32 @@ +add_executable(qtpotato + main.cpp + mpv/mpvitem.cpp + mpv/mpvitem.h +) + +ecm_add_qml_module(qtpotato + URI dev.trivk.qtpotato +) + +ecm_target_qml_sources(qtpotato SOURCES + qml/Main.qml + qml/PlayerPage.qml +) + +target_include_directories(qtpotato PRIVATE mpv) +target_compile_definitions(qtpotato PRIVATE QTPOTATO_VERSION_STRING="${PROJECT_VERSION}") + +target_link_libraries(qtpotato PRIVATE + Qt6::Gui + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + Qt6::Widgets + KF6::I18n + KF6::I18nQml + KF6::CoreAddons + KF6::Crash + MpvQt::MpvQt +) + +install(TARGETS qtpotato ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..4bcd332 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,72 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +using namespace Qt::Literals::StringLiterals; + +int main(int argc, char *argv[]) +{ + // MpvQt renders through an OpenGL framebuffer object, so Qt Quick must use OpenGL + // (not Vulkan). Has to be set before any window exists. + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + + QApplication app(argc, argv); + + // libmpv parses numbers with the C locale; Qt sets the locale from the environment. + std::setlocale(LC_NUMERIC, "C"); + + // Use the Plasma style unless the user picked another one explicitly. + if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { + QQuickStyle::setStyle(u"org.kde.desktop"_s); + } + + KLocalizedString::setApplicationDomain("qtpotato"); + + KAboutData aboutData(u"qtpotato"_s, + i18nc("@title", "QtPotato"), + QStringLiteral(QTPOTATO_VERSION_STRING), + i18n("Video player with subtitle translation"), + KAboutLicense::GPL_V3, + i18n("© 2026 Tri Vo Khac")); + aboutData.addAuthor(i18n("Tri Vo Khac"), i18nc("@info:credit", "Author"), QString(), u"https://github.com/vokhactri"_s); + aboutData.setDesktopFileName(u"dev.trivk.qtpotato"_s); + aboutData.setOrganizationDomain("trivk.dev"); + KAboutData::setApplicationData(aboutData); + QApplication::setWindowIcon(QIcon::fromTheme(u"multimedia-video-player"_s)); + + KCrash::initialize(); + + QCommandLineParser parser; + aboutData.setupCommandLine(&parser); + parser.addPositionalArgument(u"file"_s, i18n("Video file to open"), u"[file]"_s); + parser.process(app); + aboutData.processCommandLine(&parser); + + QUrl initialUrl; + if (const QStringList args = parser.positionalArguments(); !args.isEmpty()) { + initialUrl = QUrl::fromUserInput(args.first(), QDir::currentPath(), QUrl::AssumeLocalFile); + } + + QQmlApplicationEngine engine; + KLocalization::setupLocalizedContext(&engine); + engine.setInitialProperties({{u"initialUrl"_s, initialUrl}}); + engine.loadFromModule("dev.trivk.qtpotato", "Main"); + + if (engine.rootObjects().isEmpty()) { + return -1; + } + + return app.exec(); +} diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp new file mode 100644 index 0000000..7999a37 --- /dev/null +++ b/src/mpv/mpvitem.cpp @@ -0,0 +1,140 @@ +#include "mpvitem.h" + +#include + +#include + +MpvItem::MpvItem(QQuickItem *parent) + : MpvAbstractItem(parent) +{ + // Leave the player on the last frame at end of file instead of unloading it. + setPropertyAsync(QStringLiteral("keep-open"), QStringLiteral("yes")); + setPropertyAsync(QStringLiteral("hwdec"), QStringLiteral("auto-safe")); + + observeProperty(QStringLiteral("media-title"), MPV_FORMAT_STRING); + observeProperty(QStringLiteral("time-pos"), MPV_FORMAT_DOUBLE); + observeProperty(QStringLiteral("duration"), MPV_FORMAT_DOUBLE); + observeProperty(QStringLiteral("pause"), MPV_FORMAT_FLAG); + + // MpvController lives on a worker thread; queue its signals back to the GUI thread. + connect(mpvController(), &MpvController::propertyChanged, this, &MpvItem::onPropertyChanged, Qt::QueuedConnection); + connect( + mpvController(), + &MpvController::fileLoaded, + this, + [this] { + setFileLoaded(true); + }, + Qt::QueuedConnection); + connect( + mpvController(), + &MpvController::endFile, + this, + [this] { + setFileLoaded(false); + }, + Qt::QueuedConnection); + + // The render context only exists once Qt Quick has rendered the item for the first + // time; a file loaded before that plays without video. Hold it until ready() fires. + connect(this, &MpvAbstractItem::ready, this, [this] { + m_ready = true; + if (m_pendingUrl.isValid()) { + loadFile(std::exchange(m_pendingUrl, {})); + } + }); +} + +QUrl MpvItem::currentUrl() const +{ + return m_currentUrl; +} + +QString MpvItem::mediaTitle() const +{ + return m_mediaTitle; +} + +double MpvItem::position() const +{ + return m_position; +} + +double MpvItem::duration() const +{ + return m_duration; +} + +bool MpvItem::paused() const +{ + return m_paused; +} + +void MpvItem::setPaused(bool paused) +{ + if (paused == m_paused) { + return; + } + // m_paused is updated when mpv reports the "pause" property back. + setPropertyAsync(QStringLiteral("pause"), paused); +} + +bool MpvItem::isFileLoaded() const +{ + return m_fileLoaded; +} + +void MpvItem::loadFile(const QUrl &url) +{ + if (!url.isValid()) { + return; + } + + m_currentUrl = url; + Q_EMIT currentUrlChanged(); + + if (!m_ready) { + m_pendingUrl = url; + return; + } + + const QString target = url.isLocalFile() ? url.toLocalFile() : url.toString(); + commandAsync(QStringList{QStringLiteral("loadfile"), target}); + setPropertyAsync(QStringLiteral("pause"), false); +} + +void MpvItem::togglePause() +{ + setPaused(!m_paused); +} + +void MpvItem::seek(double seconds) +{ + commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("absolute")}); +} + +void MpvItem::onPropertyChanged(const QString &property, const QVariant &value) +{ + if (property == u"media-title") { + m_mediaTitle = value.toString(); + Q_EMIT mediaTitleChanged(); + } else if (property == u"time-pos") { + m_position = value.toDouble(); + Q_EMIT positionChanged(); + } else if (property == u"duration") { + m_duration = value.toDouble(); + Q_EMIT durationChanged(); + } else if (property == u"pause") { + m_paused = value.toBool(); + Q_EMIT pausedChanged(); + } +} + +void MpvItem::setFileLoaded(bool loaded) +{ + if (loaded == m_fileLoaded) { + return; + } + m_fileLoaded = loaded; + Q_EMIT fileLoadedChanged(); +} diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h new file mode 100644 index 0000000..b570f05 --- /dev/null +++ b/src/mpv/mpvitem.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include + +// QML video surface backed by libmpv (through MpvQt). +// mpv properties we care about are mirrored as Qt properties so QML can bind to them. +class MpvItem : public MpvAbstractItem +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QUrl currentUrl READ currentUrl NOTIFY currentUrlChanged) + Q_PROPERTY(QString mediaTitle READ mediaTitle NOTIFY mediaTitleChanged) + Q_PROPERTY(double position READ position NOTIFY positionChanged) + Q_PROPERTY(double duration READ duration NOTIFY durationChanged) + Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged) + Q_PROPERTY(bool fileLoaded READ isFileLoaded NOTIFY fileLoadedChanged) + +public: + explicit MpvItem(QQuickItem *parent = nullptr); + ~MpvItem() override = default; + + QUrl currentUrl() const; + QString mediaTitle() const; + double position() const; + double duration() const; + bool paused() const; + void setPaused(bool paused); + bool isFileLoaded() const; + + Q_INVOKABLE void loadFile(const QUrl &url); + Q_INVOKABLE void togglePause(); + Q_INVOKABLE void seek(double seconds); + +Q_SIGNALS: + void currentUrlChanged(); + void mediaTitleChanged(); + void positionChanged(); + void durationChanged(); + void pausedChanged(); + void fileLoadedChanged(); + +private: + void onPropertyChanged(const QString &property, const QVariant &value); + void setFileLoaded(bool loaded); + + bool m_ready = false; + QUrl m_pendingUrl; + QUrl m_currentUrl; + QString m_mediaTitle; + double m_position = 0.0; + double m_duration = 0.0; + bool m_paused = false; + bool m_fileLoaded = false; +}; diff --git a/src/qml/Main.qml b/src/qml/Main.qml new file mode 100644 index 0000000..5b5e2e1 --- /dev/null +++ b/src/qml/Main.qml @@ -0,0 +1,32 @@ +import QtQuick +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard + +Kirigami.ApplicationWindow { + id: root + + // Set from C++ (main.cpp) when a file is passed on the command line. + required property url initialUrl + + width: Kirigami.Units.gridUnit * 50 + height: Kirigami.Units.gridUnit * 32 + minimumWidth: Kirigami.Units.gridUnit * 20 + minimumHeight: Kirigami.Units.gridUnit * 14 + + title: player.mediaTitle.length > 0 ? player.mediaTitle : "QtPotato" + + pageStack.initialPage: PlayerPage { + id: player + initialUrl: root.initialUrl + onAboutRequested: root.pageStack.layers.push(aboutPage) + onFullScreenToggled: root.visibility = root.visibility === Window.FullScreen ? Window.Windowed : Window.FullScreen + } + + pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar + pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn + + Component { + id: aboutPage + FormCard.AboutPage {} + } +} diff --git a/src/qml/PlayerPage.qml b/src/qml/PlayerPage.qml new file mode 100644 index 0000000..3d600e6 --- /dev/null +++ b/src/qml/PlayerPage.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Dialogs +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.ki18n + +Kirigami.Page { + id: page + + property url initialUrl + readonly property alias mediaTitle: mpv.mediaTitle + + signal aboutRequested() + signal fullScreenToggled() + + title: KI18n.i18nc("@title", "Player") + padding: 0 + + actions: [ + Kirigami.Action { + icon.name: "document-open" + text: KI18n.i18nc("@action", "Open File…") + shortcut: StandardKey.Open + onTriggered: fileDialog.open() + }, + Kirigami.Action { + icon.name: "view-fullscreen" + text: KI18n.i18nc("@action", "Full Screen") + displayHint: Kirigami.DisplayHint.IconOnly + shortcut: "F" + onTriggered: page.fullScreenToggled() + }, + Kirigami.Action { + icon.name: "help-about" + text: KI18n.i18nc("@action", "About QtPotato") + displayHint: Kirigami.DisplayHint.AlwaysHide + onTriggered: page.aboutRequested() + } + ] + + Component.onCompleted: { + if (page.initialUrl.toString().length > 0) { + mpv.loadFile(page.initialUrl) + } + } + + function formatTime(totalSeconds: real): string { + const s = Math.max(0, Math.floor(totalSeconds)) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = String(s % 60).padStart(2, "0") + return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}` + } + + Shortcut { + sequence: "Space" + onActivated: mpv.togglePause() + } + Shortcut { + sequence: "Left" + onActivated: mpv.seek(mpv.position - 5) + } + Shortcut { + sequence: "Right" + onActivated: mpv.seek(mpv.position + 5) + } + + FileDialog { + id: fileDialog + title: KI18n.i18nc("@title:window", "Open Video") + nameFilters: [ + KI18n.i18nc("@item:inlistbox file filter", "Video files (*.mkv *.mp4 *.webm *.avi *.mov *.m4v *.ts *.flv *.wmv)"), + KI18n.i18nc("@item:inlistbox file filter", "All files (*)") + ] + onAccepted: mpv.loadFile(selectedFile) + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + MpvItem { + id: mpv + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + anchors.fill: parent + color: "black" + visible: !mpv.fileLoaded + z: -1 + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: !mpv.fileLoaded + icon.name: "video-symbolic" + text: KI18n.i18nc("@info:placeholder", "No video open") + explanation: KI18n.i18nc("@info:placeholder", "Open a file or drop one here") + helpfulAction: page.actions[0] + } + + MouseArea { + anchors.fill: parent + onClicked: mpv.togglePause() + onDoubleClicked: page.fullScreenToggled() + } + + DropArea { + anchors.fill: parent + onDropped: drop => { + if (drop.hasUrls) { + mpv.loadFile(drop.urls[0]) + } + } + } + } + + QQC2.ToolBar { + Layout.fillWidth: true + position: QQC2.ToolBar.Footer + + RowLayout { + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + + QQC2.ToolButton { + enabled: mpv.fileLoaded + focusPolicy: Qt.NoFocus + icon.name: mpv.paused || !mpv.fileLoaded ? "media-playback-start" : "media-playback-pause" + text: mpv.paused || !mpv.fileLoaded ? KI18n.i18nc("@action:button", "Play") : KI18n.i18nc("@action:button", "Pause") + display: QQC2.AbstractButton.IconOnly + onClicked: mpv.togglePause() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.Label { + text: page.formatTime(seekBar.pressed ? seekBar.value : mpv.position) + font.features: { "tnum": 1 } + } + + QQC2.Slider { + id: seekBar + Layout.fillWidth: true + enabled: mpv.fileLoaded && mpv.duration > 0 + focusPolicy: Qt.NoFocus + from: 0 + to: Math.max(mpv.duration, 1) + onMoved: mpv.seek(value) + + // Follow playback, but not while the user is dragging the handle. + Binding on value { + value: mpv.position + when: !seekBar.pressed + restoreMode: Binding.RestoreNone + } + } + + QQC2.Label { + text: page.formatTime(mpv.duration) + font.features: { "tnum": 1 } + } + } + } + } +}