From 8c42ee5a72d83c07cd4d7067f5350d16f389c1e9 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 18:48:04 +0700 Subject: [PATCH 1/4] fix(settings): align settings pages with KDE HIG --- src/qml/GeneralSettings.qml | 50 +++++------ src/qml/PlaybackSettings.qml | 31 ++----- src/qml/PlaylistSettings.qml | 40 ++++----- src/qml/SettingsBasePage.qml | 1 + src/qml/SettingsHeader.qml | 32 +------ src/qml/SettingsWindow.qml | 9 +- src/qml/SubtitlesSettings.qml | 19 ++-- src/qml/TranslationSettings.qml | 148 +++++++++++++++++++------------- src/translate/apikeystore.cpp | 47 +++++++++- src/translate/apikeystore.h | 9 ++ 10 files changed, 200 insertions(+), 186 deletions(-) diff --git a/src/qml/GeneralSettings.qml b/src/qml/GeneralSettings.qml index fcda8cb..c694909 100644 --- a/src/qml/GeneralSettings.qml +++ b/src/qml/GeneralSettings.qml @@ -13,23 +13,19 @@ SettingsBasePage { required property MpvItem mpv - GridLayout { - columns: 2 + Kirigami.FormLayout { SettingsHeader { text: KI18n.i18nc("@title", "Interface") - topMargin: 0 - Layout.columnSpan: 2 Layout.fillWidth: true } - Label { - text: KI18n.i18nc("@label:listbox", "Footer style") - Layout.alignment: Qt.AlignRight - } - RowLayout { + spacing: Kirigami.Units.smallSpacing + Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Footer style") + Kirigami.FormData.buddyFor: footerStyleCombo ComboBox { + id: footerStyleCombo textRole: "text" valueRole: "value" model: [ @@ -48,9 +44,7 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - - CheckBox { + Switch { text: KI18n.i18nc("@option:check", "Show menu bar") checked: QtPotatoSettings.showMenuBar onClicked: { @@ -59,9 +53,7 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - - CheckBox { + Switch { text: KI18n.i18nc("@option:check", "Show toolbar") checked: QtPotatoSettings.showHeader onClicked: { @@ -70,13 +62,12 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:spinbox", "Maximum recent files") - Layout.alignment: Qt.AlignRight - } - RowLayout { + spacing: Kirigami.Units.smallSpacing + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Maximum recent files") + Kirigami.FormData.buddyFor: maxRecentSpin SpinBox { + id: maxRecentSpin from: 0 to: 50 value: QtPotatoSettings.maxRecentFiles @@ -93,14 +84,12 @@ SettingsBasePage { SettingsHeader { text: KI18n.i18nc("@title", "mpv") - Layout.columnSpan: 2 Layout.fillWidth: true } - Item { Layout.preferredWidth: 1 } - RowLayout { - CheckBox { + spacing: Kirigami.Units.smallSpacing + Switch { text: KI18n.i18nc("@option:check", "Use mpv.conf") checked: QtPotatoSettings.loadMpvConfig onClicked: { @@ -114,7 +103,13 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } + Label { + text: KI18n.i18nc("@info", "Restart QtPotato to apply changes to this setting or to mpv.conf.") + wrapMode: Text.WordWrap + font: Kirigami.Theme.smallFont + color: Kirigami.Theme.disabledTextColor + Layout.fillWidth: true + } Label { text: root.mpv.loadedUserConfigFiles.length > 0 @@ -127,11 +122,6 @@ SettingsBasePage { Layout.fillWidth: true } - Item { - Layout.preferredWidth: 1 - visible: root.mpv.skippedUserOptions.length > 0 - } - Label { visible: root.mpv.skippedUserOptions.length > 0 text: KI18n.i18nc("@info", "Ignored because they would break the built-in player: %1", root.mpv.skippedUserOptions.join(", ")) diff --git a/src/qml/PlaybackSettings.qml b/src/qml/PlaybackSettings.qml index a027842..84883c6 100644 --- a/src/qml/PlaybackSettings.qml +++ b/src/qml/PlaybackSettings.qml @@ -5,27 +5,21 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import org.kde.kirigami as Kirigami import org.kde.ki18n SettingsBasePage { id: root - GridLayout { - columns: 2 + Kirigami.FormLayout { SettingsHeader { text: KI18n.i18nc("@title", "Seeking") - topMargin: 0 - Layout.columnSpan: 2 Layout.fillWidth: true } - Label { - text: KI18n.i18nc("@label:spinbox", "Small step") - Layout.alignment: Qt.AlignRight - } - SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Small step") from: 1 to: 600 value: QtPotatoSettings.seekSmallStep @@ -37,12 +31,8 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:spinbox", "Medium step") - Layout.alignment: Qt.AlignRight - } - SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Medium step") from: 1 to: 600 value: QtPotatoSettings.seekMediumStep @@ -54,12 +44,8 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:spinbox", "Big step") - Layout.alignment: Qt.AlignRight - } - SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Big step") from: 1 to: 600 value: QtPotatoSettings.seekBigStep @@ -73,16 +59,11 @@ SettingsBasePage { SettingsHeader { text: KI18n.i18nc("@title", "Audio") - Layout.columnSpan: 2 Layout.fillWidth: true } - Label { - text: KI18n.i18nc("@label:spinbox", "Volume step") - Layout.alignment: Qt.AlignRight - } - SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Volume step") from: 1 to: 100 value: QtPotatoSettings.volumeStep diff --git a/src/qml/PlaylistSettings.qml b/src/qml/PlaylistSettings.qml index f6d24f8..dbc8b84 100644 --- a/src/qml/PlaylistSettings.qml +++ b/src/qml/PlaylistSettings.qml @@ -5,20 +5,18 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import org.kde.kirigami as Kirigami import org.kde.ki18n SettingsBasePage { id: root - GridLayout { - columns: 2 - - Label { - text: KI18n.i18nc("@label:listbox", "Position") - Layout.alignment: Qt.AlignRight - } + Kirigami.FormLayout { ComboBox { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Position") textRole: "text" valueRole: "value" model: [ @@ -32,12 +30,10 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:listbox", "When a file ends") - Layout.alignment: Qt.AlignRight - } - ComboBox { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "When a file ends") textRole: "text" valueRole: "value" model: [ @@ -53,11 +49,10 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - RowLayout { - CheckBox { - text: KI18n.i18nc("@option:check", "Add the other files of the folder") + spacing: Kirigami.Units.smallSpacing + Switch { + text: KI18n.i18nc("@option:check", "Add other files from the folder") checked: QtPotatoSettings.playlistLoadSiblings onClicked: { QtPotatoSettings.playlistLoadSiblings = checked @@ -70,9 +65,7 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - - CheckBox { + Switch { text: KI18n.i18nc("@option:check", "Overlay video") checked: QtPotatoSettings.playlistOverlayVideo onClicked: { @@ -81,10 +74,9 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - RowLayout { - CheckBox { + spacing: Kirigami.Units.smallSpacing + Switch { text: KI18n.i18nc("@option:check", "Open with the mouse") checked: QtPotatoSettings.playlistToggleWithMouse onClicked: { @@ -98,9 +90,7 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - - CheckBox { + Switch { text: KI18n.i18nc("@option:check", "Show row numbers") checked: QtPotatoSettings.playlistShowRowNumber onClicked: { diff --git a/src/qml/SettingsBasePage.qml b/src/qml/SettingsBasePage.qml index 9ee70b6..835e53a 100644 --- a/src/qml/SettingsBasePage.qml +++ b/src/qml/SettingsBasePage.qml @@ -6,6 +6,7 @@ import org.kde.kirigami as Kirigami // Page of the settings window. Changes are saved as they are made. Kirigami.ScrollablePage { + padding: Kirigami.Units.largeSpacing globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None Component.onDestruction: QtPotatoSettings.save() } diff --git a/src/qml/SettingsHeader.qml b/src/qml/SettingsHeader.qml index d7d06f4..db78fcb 100644 --- a/src/qml/SettingsHeader.qml +++ b/src/qml/SettingsHeader.qml @@ -2,36 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick -import QtQuick.Layouts import org.kde.kirigami as Kirigami -// Section title of a settings page, between two lines. -ColumnLayout { - id: root - +// FormLayout supplies the standard section heading and group spacing. +Item { property string text: "" - property int topMargin: Kirigami.Units.gridUnit - spacing: 0 - - RowLayout { - Layout.topMargin: root.topMargin - - Rectangle { - color: Kirigami.Theme.alternateBackgroundColor - Layout.preferredWidth: Kirigami.Units.gridUnit - Layout.preferredHeight: 1 - } - - Kirigami.Heading { - text: root.text - level: 1 - } - - Rectangle { - color: Kirigami.Theme.alternateBackgroundColor - Layout.preferredHeight: 1 - Layout.fillWidth: true - } - } + Kirigami.FormData.label: text + Kirigami.FormData.isSection: true } diff --git a/src/qml/SettingsWindow.qml b/src/qml/SettingsWindow.qml index 3e86d41..9fd748b 100644 --- a/src/qml/SettingsWindow.qml +++ b/src/qml/SettingsWindow.qml @@ -109,10 +109,10 @@ Loader { property Item currentPage: null - width: 800 - height: 600 - minimumWidth: 750 - minimumHeight: 400 + width: Kirigami.Units.gridUnit * 45 + height: Kirigami.Units.gridUnit * 34 + minimumWidth: Kirigami.Units.gridUnit * 41 + minimumHeight: Kirigami.Units.gridUnit * 22 title: KI18n.i18nc("@title:window", "Settings") visible: true @@ -132,6 +132,7 @@ Loader { text: modelData.text icon.name: modelData.iconName highlighted: index === ListView.view.currentIndex + font.bold: highlighted onClicked: settingsWindow.showPage(modelData.name) } } diff --git a/src/qml/SubtitlesSettings.qml b/src/qml/SubtitlesSettings.qml index f526c73..43c7fee 100644 --- a/src/qml/SubtitlesSettings.qml +++ b/src/qml/SubtitlesSettings.qml @@ -5,21 +5,20 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import org.kde.kirigami as Kirigami import org.kde.ki18n SettingsBasePage { id: root - GridLayout { - columns: 2 - - Label { - text: KI18n.i18nc("@label:spinbox", "Font size") - Layout.alignment: Qt.AlignRight - } + Kirigami.FormLayout { RowLayout { + spacing: Kirigami.Units.smallSpacing + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Font size") + Kirigami.FormData.buddyFor: fontSizeSpin SpinBox { + id: fontSizeSpin from: 20 to: 150 stepSize: 2 @@ -35,12 +34,8 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:spinbox", "Outline size") - Layout.alignment: Qt.AlignRight - } - SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Outline size") from: 0 to: 10 value: QtPotatoSettings.subtitleOutlineSize diff --git a/src/qml/TranslationSettings.qml b/src/qml/TranslationSettings.qml index 5eb8581..ca08316 100644 --- a/src/qml/TranslationSettings.qml +++ b/src/qml/TranslationSettings.qml @@ -13,44 +13,73 @@ SettingsBasePage { required property TranslationService translator ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + Label { + text: KI18n.i18nc("@info", "Use an OpenAI-compatible API to translate subtitles. Tracks are translated in batches and saved for reuse.") + wrapMode: Text.WordWrap + Layout.preferredWidth: form.implicitWidth + Layout.alignment: Qt.AlignHCenter + } + Kirigami.InlineMessage { - Layout.fillWidth: true + Layout.preferredWidth: form.implicitWidth + Layout.alignment: Qt.AlignHCenter type: Kirigami.MessageType.Error - text: root.translator.errorText || ApiKeyStore.error + text: root.translator.errorText visible: text.length > 0 showCloseButton: true onVisibleChanged: if (!visible) root.translator.clearError() } - GridLayout { - columns: 2 - - Label { - text: KI18n.i18nc("@label:textbox", "API base URL") - Layout.alignment: Qt.AlignRight - } + Kirigami.FormLayout { + id: form TextField { + Kirigami.FormData.label: KI18n.i18nc("@label:textbox", "API base URL") + id: apiUrlField + // Keep an invalid draft in the field; only valid URLs reach KConfig. + property bool edited: false + readonly property bool valid: /^https?:\/\/(?:\[[0-9a-f:.]+\]|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.?)(?::[0-9]+)?(?:[/?#][^\s]*)?$/i.test(text.trim()) + + function saveUrl() { + if (valid) { + QtPotatoSettings.apiBaseUrl = text.trim() + QtPotatoSettings.save() + } + } + placeholderText: "https://example.com/v1" text: QtPotatoSettings.apiBaseUrl inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase Layout.fillWidth: true - onTextEdited: QtPotatoSettings.apiBaseUrl = text.trim() - onEditingFinished: QtPotatoSettings.save() + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + onTextEdited: edited = true + onEditingFinished: saveUrl() } - Label { - text: KI18n.i18nc("@label:textbox", "API key") - Layout.alignment: Qt.AlignRight + Kirigami.InlineMessage { + Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + type: Kirigami.MessageType.Error + text: KI18n.i18nc("@info", "Enter a valid API base URL with http:// or https:// and a host name.") + visible: !apiUrlField.valid && (apiUrlField.edited || apiUrlField.text.length > 0) } RowLayout { + spacing: Kirigami.Units.smallSpacing + Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + Kirigami.FormData.label: KI18n.i18nc("@label:textbox", "API key") + Kirigami.FormData.buddyFor: apiKeyField Kirigami.PasswordField { id: apiKeyField + enabled: !ApiKeyStore.busy placeholderText: ApiKeyStore.hasKey ? KI18n.i18nc("@info:placeholder", "Saved in the wallet. Type a new key to replace it.") : KI18n.i18nc("@info:placeholder", "Not set") Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 onEditingFinished: { if (text.trim().length > 0) { ApiKeyStore.setKey(text) @@ -61,6 +90,7 @@ SettingsBasePage { Button { visible: ApiKeyStore.hasKey + enabled: !ApiKeyStore.busy icon.name: "edit-delete-remove" text: KI18n.i18nc("@action:button", "Remove") onClicked: ApiKeyStore.setKey("") @@ -71,12 +101,26 @@ SettingsBasePage { } } - Label { - text: KI18n.i18nc("@label:listbox", "Model") - Layout.alignment: Qt.AlignRight + Kirigami.InlineMessage { + Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + type: Kirigami.MessageType.Error + text: ApiKeyStore.error + visible: text.length > 0 + actions: Kirigami.Action { + text: KI18n.i18nc("@action:button", "Retry") + icon.name: "view-refresh" + enabled: !ApiKeyStore.busy + onTriggered: ApiKeyStore.retry() + } } RowLayout { + spacing: Kirigami.Units.smallSpacing + Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Model") + Kirigami.FormData.buddyFor: modelCombo ComboBox { id: modelCombo @@ -95,6 +139,7 @@ SettingsBasePage { editable: true Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 onEditTextChanged: if (!updatingList) QtPotatoSettings.model = editText.trim() onActivated: QtPotatoSettings.save() Component.onCompleted: showModels() @@ -112,8 +157,11 @@ SettingsBasePage { text: root.translator.fetchingModels ? KI18n.i18nc("@action:button", "Loading…") : KI18n.i18nc("@action:button", "Load Models") - enabled: !root.translator.fetchingModels - onClicked: root.translator.fetchModels() + enabled: apiUrlField.valid && !root.translator.fetchingModels + onClicked: { + apiUrlField.saveUrl() + root.translator.fetchModels() + } ToolTip.text: KI18n.i18nc("@info:tooltip", "Ask the API which models it offers") ToolTip.visible: hovered @@ -121,11 +169,6 @@ SettingsBasePage { } } - Item { - Layout.preferredWidth: 1 - visible: modelCountLabel.visible - } - Label { id: modelCountLabel visible: root.translator.availableModels.length > 0 @@ -133,26 +176,20 @@ SettingsBasePage { opacity: 0.7 } - Label { - text: KI18n.i18nc("@label:textbox", "Translate into") - Layout.alignment: Qt.AlignRight - } - TextField { + Kirigami.FormData.label: KI18n.i18nc("@label:textbox", "Translate into") text: QtPotatoSettings.targetLanguage Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 onTextEdited: QtPotatoSettings.targetLanguage = text.trim() onEditingFinished: QtPotatoSettings.save() } - Label { - text: KI18n.i18nc("@label:listbox", "Preset") - Layout.alignment: Qt.AlignRight | Qt.AlignVCenter - } - ComboBox { + Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Preset") id: presetCombo Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 model: root.translator.translationPresets textRole: "name" valueRole: "id" @@ -163,46 +200,49 @@ SettingsBasePage { } } - Item { Layout.preferredWidth: 1 } - Label { text: presetCombo.currentIndex >= 0 ? presetCombo.model[presetCombo.currentIndex].description : "" wrapMode: Text.WordWrap opacity: 0.7 font: Kirigami.Theme.smallFont Layout.fillWidth: true - } - - Label { - text: KI18n.i18nc("@label:textbox", "Additional instructions") - Layout.alignment: Qt.AlignRight | Qt.AlignBaseline + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 } TextArea { + Kirigami.FormData.label: KI18n.i18nc("@label:textbox", "Additional instructions") text: QtPotatoSettings.customInstructions placeholderText: KI18n.i18nc("@info:placeholder", "e.g. Translate 'Young Master' as 'Thiếu gia'. Use 'ta/ngươi' between enemies.") wrapMode: TextArea.Wrap Layout.fillWidth: true - Layout.alignment: Qt.AlignBaseline - Layout.preferredHeight: Kirigami.Units.gridUnit * 4 + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 + Kirigami.FormData.labelAlignment: Qt.AlignTop + implicitHeight: Math.max(contentHeight + topPadding + bottomPadding, Kirigami.Units.gridUnit * 4) // TextArea has no textEdited before Qt 6.9. onTextChanged: QtPotatoSettings.customInstructions = text onEditingFinished: QtPotatoSettings.save() } - CheckBox { - text: KI18n.i18nc("@option:check", "Build a glossary automatically before translating") + Switch { + text: KI18n.i18nc("@option:check", "Build glossary automatically") checked: QtPotatoSettings.autoBuildGlossary - Layout.columnSpan: 2 - onToggled: { QtPotatoSettings.autoBuildGlossary = checked; QtPotatoSettings.save() } + onClicked: { + QtPotatoSettings.autoBuildGlossary = checked + QtPotatoSettings.save() + } } Label { - text: KI18n.i18nc("@label:spinbox", "Lines per request") - Layout.alignment: Qt.AlignRight + text: KI18n.i18nc("@info", "Collect names and terms before translating.") + wrapMode: Text.WordWrap + font: Kirigami.Theme.smallFont + color: Kirigami.Theme.disabledTextColor + Layout.fillWidth: true + Layout.preferredWidth: Kirigami.Units.gridUnit * 19 } SpinBox { + Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Lines per request") from: 5 to: 200 stepSize: 5 @@ -212,16 +252,6 @@ SettingsBasePage { QtPotatoSettings.save() } } - - Item { Layout.preferredWidth: 1 } - - Label { - text: KI18n.i18nc("@info", "Works with any OpenAI-compatible API. The whole subtitle track is translated in batches before it is shown, and the result is kept so the same track is never translated twice.") - wrapMode: Text.WordWrap - opacity: 0.7 - Layout.fillWidth: true - Layout.topMargin: Kirigami.Units.largeSpacing - } } } } diff --git a/src/translate/apikeystore.cpp b/src/translate/apikeystore.cpp index e08bf53..54979b9 100644 --- a/src/translate/apikeystore.cpp +++ b/src/translate/apikeystore.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include @@ -48,9 +50,31 @@ QString ApiKeyStore::error() const return m_error; } +bool ApiKeyStore::busy() const +{ + return m_busy; +} + void ApiKeyStore::setKey(const QString &key) { - store(key.trimmed()); + if (m_busy) { + return; + } + m_pendingKey = key.trimmed(); + m_retryStore = true; + store(m_pendingKey); +} + +void ApiKeyStore::retry() +{ + if (m_busy) { + return; + } + if (m_retryStore) { + store(m_pendingKey); + } else { + refresh(); + } } QCoro::Task ApiKeyStore::readKey() @@ -63,13 +87,14 @@ QCoro::Task ApiKeyStore::readKey() co_return ReadResult{}; } if (job->error() != QKeychain::NoError) { - co_return ReadResult{{}, job->errorString()}; + co_return ReadResult{{}, i18nc("@info", "Could not read the API key from the system wallet.\nMake sure KWallet or Secret Service is running, then retry.")}; } co_return ReadResult{job->textData(), {}}; } QCoro::Task<> ApiKeyStore::refresh() { + setBusy(true); QPointer self(this); const ReadResult result = co_await readKey(); if (!self) { @@ -77,10 +102,12 @@ QCoro::Task<> ApiKeyStore::refresh() } setHasKey(!result.key.isEmpty()); setError(result.error); + setBusy(false); } QCoro::Task<> ApiKeyStore::store(QString key) { + setBusy(true); QPointer self(this); std::unique_ptr job; if (key.isEmpty()) { @@ -97,11 +124,17 @@ QCoro::Task<> ApiKeyStore::store(QString key) } if (job->error() != QKeychain::NoError && job->error() != QKeychain::EntryNotFound) { - setError(job->errorString()); + setError(key.isEmpty() + ? i18nc("@info", "Could not remove the API key from the system wallet.\nMake sure KWallet or Secret Service is running, then retry.") + : i18nc("@info", "Could not store the API key in the system wallet.\nMake sure KWallet or Secret Service is running, then retry.")); + setBusy(false); co_return; } setError({}); setHasKey(!key.isEmpty()); + m_retryStore = false; + m_pendingKey.clear(); + setBusy(false); } void ApiKeyStore::setHasKey(bool hasKey) @@ -119,3 +152,11 @@ void ApiKeyStore::setError(const QString &error) Q_EMIT errorChanged(); } } + +void ApiKeyStore::setBusy(bool busy) +{ + if (busy != m_busy) { + m_busy = busy; + Q_EMIT busyChanged(); + } +} diff --git a/src/translate/apikeystore.h b/src/translate/apikeystore.h index 56040b3..fd2fcd1 100644 --- a/src/translate/apikeystore.h +++ b/src/translate/apikeystore.h @@ -15,6 +15,7 @@ class ApiKeyStore : public QObject Q_PROPERTY(bool hasKey READ hasKey NOTIFY hasKeyChanged) Q_PROPERTY(QString error READ error NOTIFY errorChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) public: struct ReadResult { @@ -26,9 +27,12 @@ public: bool hasKey() const; QString error() const; + bool busy() const; // Stores \a key, or removes the stored key when it is empty. Q_INVOKABLE void setKey(const QString &key); + // Repeats the failed read, write, or removal without asking for the key again. + Q_INVOKABLE void retry(); // An empty key without error means none is stored. static QCoro::Task readKey(); @@ -36,13 +40,18 @@ public: Q_SIGNALS: void hasKeyChanged(); void errorChanged(); + void busyChanged(); private: QCoro::Task<> refresh(); QCoro::Task<> store(QString key); void setHasKey(bool hasKey); void setError(const QString &error); + void setBusy(bool busy); bool m_hasKey = false; QString m_error; + bool m_busy = false; + bool m_retryStore = false; + QString m_pendingKey; }; -- 2.54.0 From e7760e22e039a144545b64c3801d13fcb8f84c41 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 18:48:04 +0700 Subject: [PATCH 2/4] fix(glossary): handle save errors and improve playlist controls --- autotests/glossarystoretest.cpp | 32 ++++++++++ src/qml/GlossaryDialog.qml | 89 ++++++++++++++++++++++------ src/qml/Playlist.qml | 16 ++--- src/qml/PlaylistItem.qml | 42 ++++++++++++- src/translate/glossarystore.cpp | 18 +++++- src/translate/translationservice.cpp | 34 +++++++++-- src/translate/translationservice.h | 6 +- 7 files changed, 204 insertions(+), 33 deletions(-) diff --git a/autotests/glossarystoretest.cpp b/autotests/glossarystoretest.cpp index 6c7f9a1..fd00096 100644 --- a/autotests/glossarystoretest.cpp +++ b/autotests/glossarystoretest.cpp @@ -2,6 +2,8 @@ #include "glossarystore.h" +#include +#include #include #include using namespace Qt::Literals::StringLiterals; @@ -24,6 +26,36 @@ private Q_SLOTS: QCOMPARE(merged.entries.at(0).target, u"Lâm"_s); QCOMPARE(merged.entries.at(1).source, u"Sect"_s); } + + void saveFailureReturnsError() + { + QTemporaryDir temp; + QVERIFY(temp.isValid()); + QStandardPaths::setTestModeEnabled(true); + + const QString appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + const QString glossaries = appData + u"/glossaries"_s; + QDir(glossaries).removeRecursively(); + QVERIFY(QDir().mkpath(appData)); + QFile blocker(glossaries); + QVERIFY(blocker.open(QIODevice::WriteOnly)); + blocker.write("blocked"); + blocker.close(); + + GlossaryStore::Glossary glossary{temp.path(), {{u"Original"_s, u"Translation"_s}}}; + QString error; + QVERIFY(!GlossaryStore::save(glossary, &error)); + QVERIFY(!error.isEmpty()); + + QFile::remove(glossaries); + } + + void saveRejectsIncompleteEntry() + { + QString error; + QVERIFY(!GlossaryStore::save({QDir::tempPath(), {{u"Original"_s, {}}}}, &error)); + QVERIFY(error.contains(u"both"_s)); + } }; QTEST_MAIN(GlossaryStoreTest) diff --git a/src/qml/GlossaryDialog.qml b/src/qml/GlossaryDialog.qml index c2b5970..b940d54 100644 --- a/src/qml/GlossaryDialog.qml +++ b/src/qml/GlossaryDialog.qml @@ -14,10 +14,20 @@ Kirigami.Dialog { required property var translator readonly property real removeButtonWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2 + readonly property bool hasIncompleteTerms: { + for (let i = 0; i < glossaryModel.count; ++i) { + const entry = glossaryModel.get(i) + if (!entry.source.trim() || !entry.target.trim()) { + return true + } + } + return false + } + property string saveError: "" // One inset for the header, content and footer so every edge lines up. readonly property real inset: Kirigami.Units.largeSpacing - title: KI18n.i18nc("@title:window", "Translation Glossary") + title: KI18n.i18nc("@title:window", "Edit Translation Glossary") standardButtons: Kirigami.Dialog.NoButton width: Math.min(parent.width * 0.9, Kirigami.Units.gridUnit * 38) height: Math.min(parent.height - Kirigami.Units.largeSpacing * 2, Kirigami.Units.gridUnit * 30) @@ -53,25 +63,28 @@ Kirigami.Dialog { }) } + function saveGlossary() { + const entries = [] + for (let i = 0; i < glossaryModel.count; ++i) { + const entry = glossaryModel.get(i) + entries.push({ source: entry.source, target: entry.target }) + } + if (!translator.saveGlossary(entries)) { + saveError = translator.glossaryError || KI18n.i18nc("@info", "Could not save the translation glossary. Check that the storage location is writable.") + return + } + saveError = "" + root.accept() + } + onOpened: { + saveError = "" glossaryModel.clear() for (const entry of translator.glossaryEntries) { glossaryModel.append(entry) } glossaryList.positionViewAtBeginning() } - onAccepted: { - const entries = [] - for (let i = 0; i < glossaryModel.count; ++i) { - const entry = glossaryModel.get(i) - entries.push({ - source: entry.source, - target: entry.target - }) - } - translator.saveGlossary(entries) - } - ListModel { id: glossaryModel } @@ -94,7 +107,8 @@ Kirigami.Dialog { Button { text: KI18n.i18nc("@action:button", "Save") icon.name: "document-save" - onClicked: root.accept() + enabled: !root.hasIncompleteTerms + onClicked: root.saveGlossary() } Button { @@ -112,8 +126,41 @@ Kirigami.Dialog { onTriggered: root.addTerm() } - contentItem: ColumnLayout { - spacing: Kirigami.Units.largeSpacing + contentItem: ScrollView { + id: contentScroll + clip: true + contentWidth: availableWidth + contentHeight: contentColumn.height + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + // Fill the dialog normally; only scroll as a whole when the window is too + // short for the list's minimum height. + ColumnLayout { + id: contentColumn + width: contentScroll.availableWidth + height: Math.max(contentScroll.availableHeight, implicitHeight) + spacing: Kirigami.Units.largeSpacing + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: root.saveError.length > 0 + type: Kirigami.MessageType.Error + text: root.saveError + showCloseButton: true + actions: Kirigami.Action { + text: KI18n.i18nc("@action:button", "Retry") + icon.name: "view-refresh" + onTriggered: root.saveGlossary() + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: root.hasIncompleteTerms + type: Kirigami.MessageType.Warning + text: KI18n.i18nc("@info", "Complete both fields for each term, or remove incomplete rows, before saving.") + showCloseButton: false + } RowLayout { Layout.fillWidth: true @@ -169,6 +216,8 @@ Kirigami.Dialog { Item { Layout.fillWidth: true Layout.fillHeight: true + Layout.minimumHeight: Kirigami.Units.gridUnit * 6 + Layout.preferredHeight: Kirigami.Units.gridUnit * 6 ScrollView { id: listScroll @@ -240,6 +289,8 @@ Kirigami.Dialog { Layout.preferredWidth: 1 Layout.minimumWidth: 0 text: termRow.source + // Show the start of long terms, not the end. + Component.onCompleted: cursorPosition = 0 placeholderText: KI18n.i18nc("@info:placeholder glossary source term", "Original term") Accessible.name: KI18n.i18nc("@label:textbox glossary source term", "Original term") onTextEdited: glossaryModel.setProperty(termRow.index, "source", text) @@ -250,12 +301,14 @@ Kirigami.Dialog { Layout.preferredWidth: 1 Layout.minimumWidth: 0 text: termRow.target + // Show the start of long terms, not the end. + Component.onCompleted: cursorPosition = 0 placeholderText: KI18n.i18nc("@info:placeholder glossary translated term", "Translation") Accessible.name: KI18n.i18nc("@label:textbox glossary translated term", "Translation") onTextEdited: glossaryModel.setProperty(termRow.index, "target", text) } - ToolButton { + Button { Layout.preferredWidth: root.removeButtonWidth Layout.minimumWidth: root.removeButtonWidth Layout.maximumWidth: root.removeButtonWidth @@ -264,6 +317,7 @@ Kirigami.Dialog { icon.name: "edit-delete" icon.color: Kirigami.Theme.textColor display: AbstractButton.IconOnly + Accessible.name: text text: KI18n.i18nc("@action:button remove glossary term", "Remove") ToolTip.text: text ToolTip.visible: hovered @@ -284,4 +338,5 @@ Kirigami.Dialog { } } } + } } diff --git a/src/qml/Playlist.qml b/src/qml/Playlist.qml index d4a9227..6dc24a6 100644 --- a/src/qml/Playlist.qml +++ b/src/qml/Playlist.qml @@ -20,13 +20,13 @@ Page { property bool fullscreen: false readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge - readonly property int buttonSize: Kirigami.Units.iconSizes.small + readonly property int buttonSize: Kirigami.Units.iconSizes.smallMedium property real customWidth: QtPotatoSettings.playlistWidth signal addFilesRequested() function limitWidth(width: real): real { - return Math.min(Math.max(width, 260), mainWindowWidth - 50) + return Math.min(Math.max(width, Kirigami.Units.gridUnit * 13), mainWindowWidth - Kirigami.Units.gridUnit * 2.5) } function toggle() { @@ -63,7 +63,7 @@ Page { icon.name: "list-add" icon.width: root.buttonSize icon.height: root.buttonSize - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus onClicked: root.addFilesRequested() } @@ -73,7 +73,7 @@ Page { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus enabled: root.playlist.playingIndex >= 0 onClicked: playlistView.positionViewAtIndex(root.playlist.playingIndex, ListView.Center) @@ -88,7 +88,7 @@ Page { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus enabled: root.playlist.count > 0 onClicked: root.playlist.clear() @@ -141,7 +141,7 @@ Page { ResizeHandler { id: resizeHandlerTall anchors.horizontalCenter: parent.horizontalCenter - width: 8 + width: Kirigami.Units.smallSpacing * 2 height: parent.height onMoved: delta => root.customWidth = root.limitWidth(root.customWidth + (root.edge === Qt.RightEdge ? -delta : delta)) onReleased: { @@ -152,8 +152,8 @@ Page { Rectangle { anchors.centerIn: parent - width: 5 - height: 50 + width: Kirigami.Units.smallSpacing + height: Kirigami.Units.gridUnit * 2.5 color: resizeHandlerTall.hovered ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor radius: Kirigami.Units.cornerRadius border.width: 1 diff --git a/src/qml/PlaylistItem.qml b/src/qml/PlaylistItem.qml index 9ce0577..0c545e3 100644 --- a/src/qml/PlaylistItem.qml +++ b/src/qml/PlaylistItem.qml @@ -22,6 +22,25 @@ Item { readonly property bool hovered: hoverHandler.hovered readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize + activeFocusOnTab: true + + function activate() { + root.playlist.play(root.index) + } + + function showContextMenu() { + contextMenu.popup() + } + + Keys.onReturnPressed: root.activate() + Keys.onEnterPressed: root.activate() + Keys.onMenuPressed: root.showContextMenu() + Keys.onPressed: event => { + if (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier)) { + root.showContextMenu() + event.accepted = true + } + } function labelColor(): color { if (root.isPlaying && !root.hovered) { @@ -39,6 +58,7 @@ Item { TapHandler { acceptedButtons: Qt.LeftButton + onTapped: root.forceActiveFocus(Qt.MouseFocusReason) onDoubleTapped: root.playlist.play(root.index) } @@ -60,6 +80,9 @@ Item { return Qt.alpha(Kirigami.Theme.backgroundColor, root.alpha) } + border.width: root.activeFocus ? 2 : 0 + border.color: Kirigami.Theme.focusColor + RowLayout { anchors.fill: parent spacing: Kirigami.Units.largeSpacing @@ -95,8 +118,8 @@ Item { color: root.labelColor() visible: root.isPlaying Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing - Layout.preferredWidth: Kirigami.Units.iconSizes.small - Layout.preferredHeight: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium + Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium } LabelWithTooltip { @@ -105,11 +128,26 @@ Item { color: root.labelColor() elide: Text.ElideRight font.pointSize: root.fontSize + font.bold: root.isPlaying verticalAlignment: Qt.AlignVCenter Layout.fillWidth: true Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0 Layout.rightMargin: Kirigami.Units.largeSpacing } + + Button { + text: KI18n.i18nc("@action:button remove playlist item", "Remove") + icon.name: "list-remove" + display: AbstractButton.IconOnly + flat: true + 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 + onClicked: root.playlist.remove(root.index) + } } } diff --git a/src/translate/glossarystore.cpp b/src/translate/glossarystore.cpp index 2cdcd45..ad156f1 100644 --- a/src/translate/glossarystore.cpp +++ b/src/translate/glossarystore.cpp @@ -56,12 +56,28 @@ GlossaryStore::Glossary GlossaryStore::load(const QString &directory) bool GlossaryStore::save(const Glossary &glossary, QString *error) { const QString directory = QDir::cleanPath(glossary.directory); + for (const Entry &entry : glossary.entries) { + if (entry.source.trimmed().isEmpty() || entry.target.trimmed().isEmpty()) { + if (error) { + *error = QStringLiteral("Each glossary term must have both an original and a translation."); + } + return false; + } + } const QString path = pathForDirectory(directory); - QDir().mkpath(QFileInfo(path).absolutePath()); + if (!QDir().mkpath(QFileInfo(path).absolutePath()) && !QFileInfo::exists(QFileInfo(path).absolutePath())) { + if (error) { + *error = QStringLiteral("Could not create the glossary storage directory."); + } + return false; + } QSaveFile file(path); if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) { if (error) { *error = file.errorString(); + if (error->isEmpty()) { + *error = QStringLiteral("Could not write the glossary file."); + } } return false; } diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index 4e20ab7..a13a496 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -139,25 +139,51 @@ QString TranslationService::glossaryDirectory() const return m_glossary.directory; } +QString TranslationService::glossaryError() const +{ + return m_glossaryError; +} + void TranslationService::loadGlossary(const QUrl &videoUrl) { const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory); + if (!m_glossaryError.isEmpty()) { + m_glossaryError.clear(); + Q_EMIT glossaryErrorChanged(); + } Q_EMIT glossaryChanged(); } -void TranslationService::saveGlossary(const QVariantList &entries) +bool TranslationService::saveGlossary(const QVariantList &entries) { QList result; for (const QVariant &value : entries) { const QVariantMap map = value.toMap(); result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed()}); } - m_glossary.entries = result; - if (!m_glossary.directory.isEmpty()) { - GlossaryStore::save(m_glossary); + + GlossaryStore::Glossary candidate = m_glossary; + candidate.entries = result; + if (!candidate.directory.isEmpty()) { + QString error; + if (!GlossaryStore::save(candidate, &error)) { + const QString message = error.isEmpty() ? i18n("Could not save the translation glossary.") + : i18n("Could not save the translation glossary: %1", error); + if (m_glossaryError != message) { + m_glossaryError = message; + Q_EMIT glossaryErrorChanged(); + } + return false; + } + } + m_glossary = candidate; + if (!m_glossaryError.isEmpty()) { + m_glossaryError.clear(); + Q_EMIT glossaryErrorChanged(); } Q_EMIT glossaryChanged(); + return true; } void TranslationService::clearGlossary() diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h index 7eada2a..db47bd3 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -34,6 +34,7 @@ class TranslationService : public QObject Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged) Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory NOTIFY glossaryChanged) + Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged) public: explicit TranslationService(QObject *parent = nullptr); @@ -48,6 +49,7 @@ public: bool isFetchingModels() const; QVariantList glossaryEntries() const; QString glossaryDirectory() const; + QString glossaryError() const; // \a track is an entry of MpvItem::subtitleTracks. Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle); @@ -56,7 +58,7 @@ public: Q_INVOKABLE void fetchModels(); Q_INVOKABLE void clearError(); Q_INVOKABLE void loadGlossary(const QUrl &videoUrl); - Q_INVOKABLE void saveGlossary(const QVariantList &entries); + Q_INVOKABLE bool saveGlossary(const QVariantList &entries); Q_INVOKABLE void clearGlossary(); Q_SIGNALS: @@ -67,6 +69,7 @@ Q_SIGNALS: void availableModelsChanged(); void fetchingModelsChanged(); void glossaryChanged(); + void glossaryErrorChanged(); void translated(const QUrl &subtitleUrl, bool fromCache); private: @@ -98,6 +101,7 @@ private: double m_progress = 0.0; QString m_statusText; QString m_errorText; + QString m_glossaryError; QStringList m_availableModels; bool m_fetchingModels = false; GlossaryStore::Glossary m_glossary; -- 2.54.0 From b772f37c07ea9c44f7623d8bf168b82cafa76d84 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 18:48:04 +0700 Subject: [PATCH 3/4] fix(menus): align menus and actions with KDE HIG --- src/mpv/mpvitem.cpp | 18 +++++++++++++ src/mpv/mpvitem.h | 8 ++++++ src/qml/Actions.qml | 49 +++++++++++++++++++++++++++++----- src/qml/AppMenu.qml | 24 ++++++++++++++++- src/qml/AppMenuItem.qml | 8 ++++-- src/qml/AudioMenu.qml | 1 + src/qml/AudioTracksMenu.qml | 10 ++++++- src/qml/FileMenu.qml | 17 ++++++++++-- src/qml/HamburgerMenu.qml | 23 +++++----------- src/qml/HelpMenu.qml | 1 + src/qml/MpvContextMenu.qml | 1 + src/qml/PlaybackMenu.qml | 2 ++ src/qml/RecentFilesMenu.qml | 5 +++- src/qml/SettingsMenu.qml | 20 +++++++------- src/qml/SubtitleTracksMenu.qml | 13 +++++++-- src/qml/SubtitlesMenu.qml | 11 ++++---- src/qml/VideoMenu.qml | 5 ++++ src/qml/ViewMenu.qml | 1 + 18 files changed, 170 insertions(+), 47 deletions(-) diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index d110a41..f21ad90 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -45,6 +45,8 @@ MpvItem::MpvItem(QQuickItem *parent) observeProperty(QStringLiteral("volume"), MPV_FORMAT_INT64); observeProperty(QStringLiteral("volume-max"), MPV_FORMAT_INT64); observeProperty(QStringLiteral("mute"), MPV_FORMAT_FLAG); + observeProperty(QStringLiteral("sub-visibility"), MPV_FORMAT_FLAG); + observeProperty(QStringLiteral("deinterlace"), MPV_FORMAT_FLAG); observeProperty(QStringLiteral("speed"), MPV_FORMAT_DOUBLE); observeProperty(QStringLiteral("chapter-list"), MPV_FORMAT_NODE); observeProperty(QStringLiteral("chapter"), MPV_FORMAT_INT64); @@ -258,6 +260,16 @@ bool MpvItem::mute() const return m_mute; } +bool MpvItem::subtitleVisible() const +{ + return m_subtitleVisible; +} + +bool MpvItem::deinterlace() const +{ + return m_deinterlace; +} + void MpvItem::setMute(bool mute) { setPropertyAsync(QStringLiteral("mute"), mute); @@ -414,6 +426,12 @@ void MpvItem::onPropertyChanged(const QString &property, const QVariant &value) } else if (property == u"mute") { m_mute = value.toBool(); Q_EMIT muteChanged(); + } else if (property == u"sub-visibility") { + m_subtitleVisible = value.toBool(); + Q_EMIT subtitleVisibleChanged(); + } else if (property == u"deinterlace") { + m_deinterlace = value.toBool(); + Q_EMIT deinterlaceChanged(); } else if (property == u"speed") { m_speed = value.toDouble(); Q_EMIT speedChanged(); diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index 810ec9b..f37a590 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -36,6 +36,8 @@ class MpvItem : public MpvAbstractItem Q_PROPERTY(int volume READ volume WRITE setVolume NOTIFY volumeChanged) Q_PROPERTY(int volumeMax READ volumeMax NOTIFY volumeMaxChanged) Q_PROPERTY(bool mute READ mute WRITE setMute NOTIFY muteChanged) + Q_PROPERTY(bool subtitleVisible READ subtitleVisible NOTIFY subtitleVisibleChanged) + Q_PROPERTY(bool deinterlace READ deinterlace NOTIFY deinterlaceChanged) Q_PROPERTY(double speed READ speed WRITE setSpeed NOTIFY speedChanged) // Chapters of the current file, each a map with title and time (in seconds). Q_PROPERTY(QVariantList chapters READ chapters NOTIFY chaptersChanged) @@ -76,6 +78,8 @@ public: int volumeMax() const; bool mute() const; void setMute(bool mute); + bool subtitleVisible() const; + bool deinterlace() const; double speed() const; void setSpeed(double speed); QVariantList chapters() const; @@ -116,6 +120,8 @@ Q_SIGNALS: void volumeChanged(); void volumeMaxChanged(); void muteChanged(); + void subtitleVisibleChanged(); + void deinterlaceChanged(); void speedChanged(); void chaptersChanged(); void chapterChanged(); @@ -149,6 +155,8 @@ private: int m_volume = 100; int m_volumeMax = 130; bool m_mute = false; + bool m_subtitleVisible = true; + bool m_deinterlace = false; double m_speed = 1.0; QVariantList m_chapters; int m_chapter = -1; diff --git a/src/qml/Actions.qml b/src/qml/Actions.qml index 0760410..0b0e524 100644 --- a/src/qml/Actions.qml +++ b/src/qml/Actions.qml @@ -22,6 +22,9 @@ Item { readonly property bool hasFile: mpv.fileLoaded + readonly property bool subtitlesVisible: mpv.subtitleVisible + readonly property bool deinterlacingEnabled: mpv.deinterlace + signal openFileRequested() // Open a known file, such as one from Recent Files. signal fileOpenRequested(url: url) @@ -115,7 +118,7 @@ Item { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Restart Playback") icon.name: "edit-reset" - shortcut: "F5" + shortcut: "Ctrl+Shift+R" onTriggered: root.mpv.seek(0) } @@ -138,6 +141,7 @@ Item { property Action seekForwardSmall: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Small Step Forward") + icon.name: "go-next" shortcut: "Right" onTriggered: root.seekBy(QtPotatoSettings.seekSmallStep) } @@ -145,6 +149,7 @@ Item { property Action seekBackwardSmall: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Small Step Backward") + icon.name: "go-previous" shortcut: "Left" onTriggered: root.seekBy(-QtPotatoSettings.seekSmallStep) } @@ -152,6 +157,7 @@ Item { property Action seekForwardMedium: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Medium Step Forward") + icon.name: "media-seek-forward" shortcut: "Shift+Right" onTriggered: root.seekBy(QtPotatoSettings.seekMediumStep) } @@ -159,6 +165,7 @@ Item { property Action seekBackwardMedium: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Medium Step Backward") + icon.name: "media-seek-backward" shortcut: "Shift+Left" onTriggered: root.seekBy(-QtPotatoSettings.seekMediumStep) } @@ -166,6 +173,7 @@ Item { property Action seekForwardBig: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Big Step Forward") + icon.name: "arrow-right-double" shortcut: "Up" onTriggered: root.seekBy(QtPotatoSettings.seekBigStep) } @@ -173,6 +181,7 @@ Item { property Action seekBackwardBig: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek Big Step Backward") + icon.name: "arrow-left-double" shortcut: "Down" onTriggered: root.seekBy(-QtPotatoSettings.seekBigStep) } @@ -180,6 +189,7 @@ Item { property Action seekNextSubtitle: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek To Next Subtitle") + icon.name: "go-down" shortcut: "Ctrl+Right" onTriggered: root.mpv.commandAsync(["sub-seek", "1"]) } @@ -187,6 +197,7 @@ Item { property Action seekPreviousSubtitle: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Seek To Previous Subtitle") + icon.name: "go-up" shortcut: "Ctrl+Left" onTriggered: root.mpv.commandAsync(["sub-seek", "-1"]) } @@ -194,6 +205,7 @@ Item { property Action seekNextChapter: Action { enabled: root.shortcutsEnabled && root.mpv.chapters.length > 0 text: KI18n.i18nc("@action", "Seek To Next Chapter") + icon.name: "go-last" shortcut: "PgUp" onTriggered: root.mpv.commandAsync(["add", "chapter", "1"]) } @@ -201,6 +213,7 @@ Item { property Action seekPreviousChapter: Action { enabled: root.shortcutsEnabled && root.mpv.chapters.length > 0 text: KI18n.i18nc("@action", "Seek To Previous Chapter") + icon.name: "go-first" shortcut: "PgDown" onTriggered: root.mpv.commandAsync(["add", "chapter", "-1"]) } @@ -208,6 +221,7 @@ Item { property Action frameStepForward: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Step Frame Forward") + icon.name: "keyframe-next" shortcut: "." onTriggered: root.mpv.frameStep(true) } @@ -215,6 +229,7 @@ Item { property Action frameStepBackward: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Step Frame Backward") + icon.name: "keyframe-previous" shortcut: "," onTriggered: root.mpv.frameStep(false) } @@ -222,6 +237,7 @@ Item { property Action speedUp: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Increase Playback Speed") + icon.name: "media-seek-forward" shortcut: "]" onTriggered: root.changeSpeed(0.1) } @@ -229,6 +245,7 @@ Item { property Action speedDown: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Decrease Playback Speed") + icon.name: "media-seek-backward" shortcut: "[" onTriggered: root.changeSpeed(-0.1) } @@ -236,6 +253,7 @@ Item { property Action speedReset: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Reset Playback Speed") + icon.name: "speedometer" shortcut: "Backspace" onTriggered: root.changeSpeed(0) } @@ -270,6 +288,7 @@ Item { property Action audioCycleUp: Action { enabled: root.shortcutsEnabled && root.mpv.audioTracks.length > 1 text: KI18n.i18nc("@action", "Cycle Up Audio Track") + icon.name: "go-up" shortcut: "Shift+3" onTriggered: root.mpv.audioId = root.cycleTrack(root.mpv.audioTracks, root.mpv.audioId, 1) } @@ -277,6 +296,7 @@ Item { property Action audioCycleDown: Action { enabled: root.shortcutsEnabled && root.mpv.audioTracks.length > 1 text: KI18n.i18nc("@action", "Cycle Down Audio Track") + icon.name: "go-down" shortcut: "Shift+2" onTriggered: root.mpv.audioId = root.cycleTrack(root.mpv.audioTracks, root.mpv.audioId, -1) } @@ -291,12 +311,16 @@ Item { property Action subtitleToggle: Action { enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 - text: KI18n.i18nc("@action", "Toggle Subtitles") + text: KI18n.i18nc("@action", "Show Subtitles") + icon.name: "media-view-subtitles-symbolic" + checkable: true + checked: root.subtitlesVisible shortcut: "Ctrl+S" onTriggered: { const visible = root.mpv.getProperty("sub-visibility") root.mpv.setPropertyAsync("sub-visibility", !visible) root.mpv.setPropertyAsync("secondary-sub-visibility", !visible) + checked = Qt.binding(() => root.subtitlesVisible) root.osd.message(visible ? KI18n.i18nc("@info:osd", "Subtitles hidden") : KI18n.i18nc("@info:osd", "Subtitles shown")) } } @@ -304,6 +328,7 @@ Item { property Action subtitleCycleUp: Action { enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 text: KI18n.i18nc("@action", "Cycle Up Subtitle") + icon.name: "go-up" shortcut: "J" onTriggered: root.mpv.subtitleId = root.cycleTrack(root.mpv.subtitleTracks, root.mpv.subtitleId, 1) } @@ -311,6 +336,7 @@ Item { property Action subtitleCycleDown: Action { enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 text: KI18n.i18nc("@action", "Cycle Down Subtitle") + icon.name: "go-down" shortcut: "Shift+J" onTriggered: root.mpv.subtitleId = root.cycleTrack(root.mpv.subtitleTracks, root.mpv.subtitleId, -1) } @@ -318,6 +344,7 @@ Item { property Action subtitleEarlier: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Show Subtitles Earlier") + icon.name: "go-previous" shortcut: "Z" onTriggered: root.changeSubtitleDelay(-0.1) } @@ -325,6 +352,7 @@ Item { property Action subtitleLater: Action { enabled: root.shortcutsEnabled && root.hasFile text: KI18n.i18nc("@action", "Show Subtitles Later") + icon.name: "go-next" shortcut: "Shift+Z" onTriggered: root.changeSubtitleDelay(0.1) } @@ -332,12 +360,14 @@ Item { property Action subtitleDelayReset: Action { enabled: root.shortcutsEnabled && root.hasFile && root.mpv.subtitleDelay !== 0 text: KI18n.i18nc("@action", "Reset Subtitle Delay") + icon.name: "edit-reset" onTriggered: root.changeSubtitleDelay(-root.mpv.subtitleDelay) } property Action subtitleBigger: Action { enabled: root.shortcutsEnabled text: KI18n.i18nc("@action", "Increase Subtitle Font Size") + icon.name: "format-font-size-more" shortcut: "Ctrl+Z" onTriggered: { QtPotatoSettings.subtitleFontSize = Math.min(150, QtPotatoSettings.subtitleFontSize + 2) @@ -349,6 +379,7 @@ Item { property Action subtitleSmaller: Action { enabled: root.shortcutsEnabled text: KI18n.i18nc("@action", "Decrease Subtitle Font Size") + icon.name: "format-font-size-less" shortcut: "Ctrl+X" onTriggered: { QtPotatoSettings.subtitleFontSize = Math.max(20, QtPotatoSettings.subtitleFontSize - 2) @@ -392,11 +423,15 @@ Item { property Action toggleDeinterlacing: Action { enabled: root.shortcutsEnabled && root.hasFile - text: KI18n.i18nc("@action", "Toggle Deinterlacing") + text: KI18n.i18nc("@action", "Deinterlace") + icon.name: "view-filter" + checkable: true + checked: root.deinterlacingEnabled shortcut: "D" onTriggered: { - const enabled = root.mpv.getProperty("deinterlace") === "yes" - root.mpv.setPropertyAsync("deinterlace", enabled ? "no" : "yes") + const enabled = root.mpv.getProperty("deinterlace") === true + root.mpv.setPropertyAsync("deinterlace", !enabled) + checked = Qt.binding(() => root.deinterlacingEnabled) root.osd.message(enabled ? KI18n.i18nc("@info:osd", "Deinterlacing off") : KI18n.i18nc("@info:osd", "Deinterlacing on")) } } @@ -476,6 +511,7 @@ Item { property Action toggleHeader: Action { enabled: root.shortcutsEnabled text: QtPotatoSettings.showHeader ? KI18n.i18nc("@action", "Hide Toolbar") : KI18n.i18nc("@action", "Show Toolbar") + icon.name: "configure-toolbars" shortcut: "Ctrl+H" onTriggered: root.headerToggleRequested() } @@ -489,7 +525,7 @@ Item { } property Action configure: Action { - text: KI18n.i18nc("@action", "Settings") + text: KI18n.i18nc("@action", "Configure QtPotato…") icon.name: "configure" shortcut: "Ctrl+Shift+," onTriggered: root.settingsRequested("general") @@ -498,7 +534,6 @@ Item { property Action about: Action { text: KI18n.i18nc("@action", "About QtPotato") icon.name: "help-about" - shortcut: "F1" onTriggered: root.settingsRequested("about") } diff --git a/src/qml/AppMenu.qml b/src/qml/AppMenu.qml index 97f657b..0978f67 100644 --- a/src/qml/AppMenu.qml +++ b/src/qml/AppMenu.qml @@ -1,8 +1,30 @@ // SPDX-FileCopyrightText: 2026 Tri Vo Khac // SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick import QtQuick.Controls +import org.kde.kirigami as Kirigami Menu { - delegate: AppMenuItem {} + id: root + + readonly property bool hasCheckables: { + for (let i = 0; i < count; ++i) { + const item = itemAt(i) + if (item && item.checkable) { + return true + } + } + return false + } + + // Keep long filenames and translated labels from obscuring parent menus. + readonly property real maximumWidth: Math.min(Kirigami.Units.gridUnit * 30, + (root.parent && root.parent.Window.window ? root.parent.Window.window.width : Kirigami.Units.gridUnit * 30)) + width: Math.min(implicitWidth, maximumWidth) + + delegate: AppMenuItem { + icon.name: subMenu ? subMenu.icon.name : (action ? action.icon.name : "") + icon.source: subMenu ? subMenu.icon.source : (action ? action.icon.source : "") + } } diff --git a/src/qml/AppMenuItem.qml b/src/qml/AppMenuItem.qml index 9c95e61..582b5b1 100644 --- a/src/qml/AppMenuItem.qml +++ b/src/qml/AppMenuItem.qml @@ -9,6 +9,8 @@ import org.kde.kirigami as Kirigami MenuItem { id: root + property int textElide: Text.ElideRight + // org.kde.desktop sets these on its MenuItem, but other Plasma styles such // as org.kde.union do not, which would leave the label below empty. Kirigami.MnemonicData.enabled: enabled && visible @@ -33,7 +35,7 @@ MenuItem { layoutDirection: root.mirrored ? Qt.RightToLeft : Qt.LeftToRight Item { - Layout.preferredWidth: (root.ListView.view && root.ListView.view.hasCheckables) || root.checkable + Layout.preferredWidth: (root.menu && root.menu.hasCheckables) || root.checkable ? root.indicator.width : Kirigami.Units.smallSpacing } @@ -62,12 +64,14 @@ MenuItem { textFormat: Text.StyledText font: root.font color: root.pressed ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor - elide: Text.ElideRight + elide: root.textElide horizontalAlignment: root.mirrored ? Text.AlignRight : Text.AlignLeft } Label { id: shortcut + Layout.leftMargin: root.mirrored ? 0 : Kirigami.Units.largeSpacing - row.spacing + Layout.rightMargin: root.mirrored ? Kirigami.Units.largeSpacing - row.spacing : 0 Layout.alignment: Qt.AlignBaseline visible: !root.subMenu && root.action && root.action.shortcut !== undefined text: visible ? itemShortcut.nativeText : "" diff --git a/src/qml/AudioMenu.qml b/src/qml/AudioMenu.qml index 6b27383..57e2b66 100644 --- a/src/qml/AudioMenu.qml +++ b/src/qml/AudioMenu.qml @@ -8,6 +8,7 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "audio-volume-high" required property Actions actions required property MpvItem mpv diff --git a/src/qml/AudioTracksMenu.qml b/src/qml/AudioTracksMenu.qml index 458c4c9..396aea9 100644 --- a/src/qml/AudioTracksMenu.qml +++ b/src/qml/AudioTracksMenu.qml @@ -11,6 +11,7 @@ import org.kde.ki18n // Checkable list of audio tracks. AppMenu { id: root + icon.name: "audio-x-generic" required property MpvItem mpv @@ -23,14 +24,21 @@ AppMenu { enabled: mpv.audioTracks.length > 0 + ButtonGroup { id: tracksGroup; exclusive: true } + Instantiator { model: root.mpv.audioTracks delegate: AppMenuItem { + id: trackItem required property var modelData text: root.trackLabel(modelData) checkable: true + ButtonGroup.group: tracksGroup checked: modelData.id === root.mpv.audioId - onTriggered: root.mpv.audioId = modelData.id + onTriggered: { + root.mpv.audioId = modelData.id + checked = Qt.binding(() => trackItem.modelData.id === root.mpv.audioId) + } } onObjectAdded: (index, object) => root.insertItem(index, object) onObjectRemoved: (index, object) => root.removeItem(object) diff --git a/src/qml/FileMenu.qml b/src/qml/FileMenu.qml index 95d9335..524a0a4 100644 --- a/src/qml/FileMenu.qml +++ b/src/qml/FileMenu.qml @@ -8,14 +8,27 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "folder" required property Actions actions + // The hamburger must not instantiate application management commands. + property bool includeQuit: true title: KI18n.i18nc("@title:menu", "&File") AppMenuItem { action: root.actions.openFile } AppMenuItem { action: root.actions.openUrl } RecentFilesMenu { actions: root.actions } - MenuSeparator {} - AppMenuItem { action: root.actions.quit } + Instantiator { + model: root.includeQuit ? 1 : 0 + delegate: MenuSeparator {} + onObjectAdded: (index, object) => root.insertItem(3, object) + onObjectRemoved: (index, object) => root.removeItem(object) + } + Instantiator { + model: root.includeQuit ? 1 : 0 + delegate: AppMenuItem { action: root.actions.quit } + onObjectAdded: (index, object) => root.insertItem(4, object) + onObjectRemoved: (index, object) => root.removeItem(object) + } } diff --git a/src/qml/HamburgerMenu.qml b/src/qml/HamburgerMenu.qml index 695884d..85704ca 100644 --- a/src/qml/HamburgerMenu.qml +++ b/src/qml/HamburgerMenu.qml @@ -13,7 +13,7 @@ ToolButton { required property Actions actions required property MpvItem mpv - // In the footer the menu opens upwards and also offers what the header would. + // In the footer the same menu opens upwards. property bool inFooter: false readonly property alias isOpen: menu.visible @@ -36,16 +36,8 @@ ToolButton { modal: true dim: false - AppMenuItem { - action: root.actions.openFile - visible: root.inFooter - height: visible ? implicitHeight : 0 - } - AppMenuItem { - action: root.actions.openUrl - visible: root.inFooter - height: visible ? implicitHeight : 0 - } + AppMenuItem { action: root.actions.openFile } + AppMenuItem { action: root.actions.openUrl } RecentFilesMenu { actions: root.actions } SubtitleTracksMenu { mpv: root.mpv @@ -67,17 +59,14 @@ ToolButton { AppMenuItem { action: root.actions.togglePlaylist } AppMenuItem { action: root.actions.screenshot } MenuSeparator {} - AppMenuItem { - action: root.actions.configure - visible: root.inFooter - height: visible ? implicitHeight : 0 - } + AppMenuItem { action: root.actions.configure } AppMenuItem { action: root.actions.toggleMenuBar } AppMenuItem { action: root.actions.about } MenuSeparator {} AppMenu { title: KI18n.i18nc("@title:menu", "&More") - FileMenu { actions: root.actions } + icon.name: "overflow-menu" + FileMenu { actions: root.actions; includeQuit: false } ViewMenu { actions: root.actions } PlaybackMenu { actions: root.actions } VideoMenu { actions: root.actions } diff --git a/src/qml/HelpMenu.qml b/src/qml/HelpMenu.qml index 65e09fc..400f86c 100644 --- a/src/qml/HelpMenu.qml +++ b/src/qml/HelpMenu.qml @@ -8,6 +8,7 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "help-contents" required property Actions actions diff --git a/src/qml/MpvContextMenu.qml b/src/qml/MpvContextMenu.qml index 784c458..689b0aa 100644 --- a/src/qml/MpvContextMenu.qml +++ b/src/qml/MpvContextMenu.qml @@ -18,6 +18,7 @@ AppMenu { AppMenuItem { action: root.actions.playPreviousFile } AppMenu { title: KI18n.i18nc("@title:menu", "Playback Speed") + icon.name: "speedometer" AppMenuItem { action: root.actions.speedUp } AppMenuItem { action: root.actions.speedDown } AppMenuItem { action: root.actions.speedReset } diff --git a/src/qml/PlaybackMenu.qml b/src/qml/PlaybackMenu.qml index 801cfab..acb1faa 100644 --- a/src/qml/PlaybackMenu.qml +++ b/src/qml/PlaybackMenu.qml @@ -8,6 +8,7 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "media-playback-start" required property Actions actions @@ -25,6 +26,7 @@ AppMenu { MenuSeparator {} AppMenu { title: KI18n.i18nc("@title:menu", "Seek") + icon.name: "go-jump" AppMenuItem { action: root.actions.seekForwardSmall } AppMenuItem { action: root.actions.seekBackwardSmall } MenuSeparator {} diff --git a/src/qml/RecentFilesMenu.qml b/src/qml/RecentFilesMenu.qml index 64e884d..eb9584b 100644 --- a/src/qml/RecentFilesMenu.qml +++ b/src/qml/RecentFilesMenu.qml @@ -6,10 +6,12 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls +import org.kde.kirigami as Kirigami import org.kde.ki18n AppMenu { id: root + icon.name: "document-open-recent" required property Actions actions @@ -23,11 +25,12 @@ AppMenu { required property string name required property string path text: name + textElide: Text.ElideMiddle onTriggered: root.actions.fileOpenRequested(url) ToolTip.text: path ToolTip.visible: hovered - ToolTip.delay: 1000 + ToolTip.delay: Kirigami.Units.toolTipDelay } onObjectAdded: (index, object) => root.insertItem(index, object) onObjectRemoved: (index, object) => root.removeItem(object) diff --git a/src/qml/SettingsMenu.qml b/src/qml/SettingsMenu.qml index a522156..1237ece 100644 --- a/src/qml/SettingsMenu.qml +++ b/src/qml/SettingsMenu.qml @@ -8,33 +8,35 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "settings-configure" required property Actions actions title: KI18n.i18nc("@title:menu", "&Settings") AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "General") + text: KI18n.i18nc("@action:inmenu", "General…") + icon.name: "configure" onTriggered: root.actions.settingsRequested("general") } AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "Playback") + text: KI18n.i18nc("@action:inmenu", "Playback…") + icon.name: "preferences-media-playback-amarok" onTriggered: root.actions.settingsRequested("playback") } AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "Subtitles") + text: KI18n.i18nc("@action:inmenu", "Subtitles…") + icon.name: "preferences-desktop-font" onTriggered: root.actions.settingsRequested("subtitles") } AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "Translation") + text: KI18n.i18nc("@action:inmenu", "Translation…") + icon.name: "translate" onTriggered: root.actions.settingsRequested("translation") } AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "Playlist") + text: KI18n.i18nc("@action:inmenu", "Playlist…") + icon.name: "view-media-playlist" onTriggered: root.actions.settingsRequested("playlist") } - AppMenuItem { - text: KI18n.i18nc("@action:inmenu", "About") - onTriggered: root.actions.settingsRequested("about") - } } diff --git a/src/qml/SubtitleTracksMenu.qml b/src/qml/SubtitleTracksMenu.qml index b6b4c41..bf59e38 100644 --- a/src/qml/SubtitleTracksMenu.qml +++ b/src/qml/SubtitleTracksMenu.qml @@ -11,6 +11,7 @@ import org.kde.ki18n // Checkable list of subtitle tracks, for the primary or the secondary subtitle. AppMenu { id: root + icon.name: isPrimary ? "text-x-generic" : "text-x-script" required property MpvItem mpv property bool isPrimary: true @@ -36,13 +37,16 @@ AppMenu { enabled: mpv.subtitleTracks.length > 0 + ButtonGroup { id: tracksGroup; exclusive: true } + AppMenuItem { text: KI18n.i18nc("@item:inmenu no subtitle", "None") checkable: true + ButtonGroup.group: tracksGroup checked: root.selectedId === 0 onTriggered: { root.select(0) - // Clicking a checked item unchecks it, which drops the binding. + // Keep mpv authoritative after MenuItem updates its checked state. checked = Qt.binding(() => root.selectedId === 0) } } @@ -50,13 +54,18 @@ AppMenu { Instantiator { model: root.mpv.subtitleTracks delegate: AppMenuItem { + id: trackItem required property var modelData text: root.trackLabel(modelData) checkable: true + ButtonGroup.group: tracksGroup checked: modelData.id === root.selectedId // mpv cannot show one track in both places. enabled: modelData.id !== root.otherId - onTriggered: root.select(modelData.id) + onTriggered: { + root.select(modelData.id) + checked = Qt.binding(() => trackItem.modelData.id === root.selectedId) + } } onObjectAdded: (index, object) => root.insertItem(index + 1, object) onObjectRemoved: (index, object) => root.removeItem(object) diff --git a/src/qml/SubtitlesMenu.qml b/src/qml/SubtitlesMenu.qml index 96626a7..6bb2b80 100644 --- a/src/qml/SubtitlesMenu.qml +++ b/src/qml/SubtitlesMenu.qml @@ -8,12 +8,14 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "media-view-subtitles-symbolic" required property Actions actions required property MpvItem mpv title: KI18n.i18nc("@title:menu", "&Subtitles") + SubtitleTracksMenu { mpv: root.mpv isPrimary: true @@ -27,18 +29,17 @@ AppMenu { MenuSeparator {} AppMenuItem { action: root.actions.translateSubtitle } AppMenuItem { action: root.actions.editGlossary } - AppMenuItem { - action: root.actions.cancelTranslation - visible: root.actions.cancelTranslation.enabled - height: visible ? implicitHeight : 0 - } + AppMenuItem { action: root.actions.cancelTranslation } MenuSeparator {} AppMenuItem { action: root.actions.addSubtitle } + MenuSeparator {} AppMenuItem { action: root.actions.subtitleBigger } AppMenuItem { action: root.actions.subtitleSmaller } + MenuSeparator {} AppMenuItem { action: root.actions.subtitleEarlier } AppMenuItem { action: root.actions.subtitleLater } AppMenuItem { action: root.actions.subtitleDelayReset } + MenuSeparator {} AppMenuItem { action: root.actions.subtitleToggle } AppMenuItem { action: root.actions.subtitleCycleUp } AppMenuItem { action: root.actions.subtitleCycleDown } diff --git a/src/qml/VideoMenu.qml b/src/qml/VideoMenu.qml index b3fc970..f352c2e 100644 --- a/src/qml/VideoMenu.qml +++ b/src/qml/VideoMenu.qml @@ -8,16 +8,21 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "video-display" required property Actions actions title: KI18n.i18nc("@title:menu", "&Video") + AppMenuItem { action: root.actions.toggleDeinterlacing } + MenuSeparator {} AppMenuItem { action: root.actions.zoomIn } AppMenuItem { action: root.actions.zoomOut } AppMenuItem { action: root.actions.zoomReset } + MenuSeparator {} AppMenuItem { action: root.actions.screenshot } + MenuSeparator {} AppMenuItem { action: root.actions.rotateClockwise } AppMenuItem { action: root.actions.rotateCounterClockwise } } diff --git a/src/qml/ViewMenu.qml b/src/qml/ViewMenu.qml index a17cf52..307f0ea 100644 --- a/src/qml/ViewMenu.qml +++ b/src/qml/ViewMenu.qml @@ -8,6 +8,7 @@ import org.kde.ki18n AppMenu { id: root + icon.name: "view-list-details" required property Actions actions -- 2.54.0 From 5edbb4a9e5a8b938c40e87029f4f54d98b65966e Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 18:48:04 +0700 Subject: [PATCH 4/4] fix(ui): address main window KDE HIG findings --- src/qml/Footer.qml | 55 ++++++++++---- src/qml/Header.qml | 45 ++++++++++-- src/qml/InputPopup.qml | 69 +++++++++++++----- src/qml/Main.qml | 17 +++-- src/qml/MpvVideo.qml | 6 +- src/qml/SeekBar.qml | 148 ++++++++++++++++++-------------------- src/qml/ToolTipButton.qml | 23 ++++-- src/qml/VolumeSlider.qml | 42 +++++------ 8 files changed, 248 insertions(+), 157 deletions(-) diff --git a/src/qml/Footer.qml b/src/qml/Footer.qml index c062bb1..a5d2aa2 100644 --- a/src/qml/Footer.qml +++ b/src/qml/Footer.qml @@ -13,6 +13,13 @@ import org.kde.kirigami as Kirigami Item { id: root + // Space activates the focused control before the window's playback action. + Keys.onShortcutOverride: event => { + if (event.key === Qt.Key_Space && event.modifiers === Qt.NoModifier) { + event.accepted = true + } + } + required property Actions actions required property MpvVideo mpv property bool fullscreen: false @@ -22,9 +29,17 @@ Item { readonly property bool isFloating: QtPotatoSettings.footerStyle === "floating" // Takes space below the video (instead of covering it). readonly property bool isDocked: !isFloating && !fullscreen - readonly property bool isSmallSize: width < 600 + readonly property bool isSmallSize: width < Kirigami.Units.gridUnit * 30 readonly property int buttonSize: isSmallSize ? Kirigami.Units.iconSizes.small : Kirigami.Units.iconSizes.smallMedium - readonly property bool busy: footer.hovered || hamburgerMenu.isOpen || seekBar.chaptersPopupOpen || seekBar.pressed + readonly property bool busy: footer.hovered || hamburgerMenu.isOpen || seekBar.chaptersPopupOpen || seekBar.pressed || controlHasFocus + readonly property bool controlHasFocus: { + let item = root.Window.window?.activeFocusItem + while (item) { + if (item === footer) return true + item = item.parent + } + return false + } property bool shown: true implicitHeight: isFloating @@ -92,7 +107,7 @@ Item { x: (root.width - width) / 2 width: root.isFloating - ? Math.min(root.width - Kirigami.Units.largeSpacing * (root.isSmallSize ? 2 : 4), 900) + ? Math.min(root.width - Kirigami.Units.largeSpacing * (root.isSmallSize ? 2 : 4), Kirigami.Units.gridUnit * 50) : root.width state: root.shown ? "visible" : "hidden" padding: Kirigami.Units.smallSpacing @@ -112,16 +127,17 @@ Item { ColumnLayout { anchors.fill: parent - spacing: 0 + spacing: Kirigami.Units.smallSpacing RowLayout { - spacing: root.isSmallSize ? 1 : Kirigami.Units.smallSpacing + spacing: Kirigami.Units.mediumSpacing HamburgerMenu { id: hamburgerMenu actions: root.actions mpv: root.mpv inFooter: true + focusPolicy: Qt.StrongFocus visible: root.showMenuButton icon.width: root.buttonSize icon.height: root.buttonSize @@ -133,7 +149,7 @@ Item { display: AbstractButton.IconOnly icon.width: root.buttonSize icon.height: root.buttonSize - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus ToolTip.text: root.mpv.paused ? KI18n.i18nc("@info:tooltip", "Start playback") : KI18n.i18nc("@info:tooltip", "Pause playback") ToolTip.visible: hovered @@ -146,7 +162,7 @@ Item { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus ToolTip.text: KI18n.i18nc("@info:tooltip", "Play previous file") ToolTip.visible: hovered @@ -159,7 +175,7 @@ Item { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus ToolTip.text: KI18n.i18nc("@info:tooltip", "Play next file") ToolTip.visible: hovered @@ -171,7 +187,6 @@ Item { mpv: root.mpv actions: root.actions Layout.fillWidth: true - Layout.preferredHeight: 20 } ToolButton { @@ -180,7 +195,7 @@ Item { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus ToolTip.text: root.actions.mute.text ToolTip.visible: hovered @@ -188,9 +203,21 @@ Item { } VolumeSlider { + id: volumeSlider mpv: root.mpv - Layout.preferredWidth: root.isSmallSize ? 60 : 100 - Layout.preferredHeight: 20 + Layout.preferredWidth: Kirigami.Units.gridUnit * (root.isSmallSize ? 3 : 5) + } + + Label { + text: root.mpv.mute ? KI18n.i18nc("@info volume muted", "Muted") : KI18n.i18nc("@info volume percentage", "%1%", Math.round(volumeSlider.value)) + font: Kirigami.Theme.smallFont + Layout.minimumWidth: volumeTextMetrics.width + + TextMetrics { + id: volumeTextMetrics + font: Kirigami.Theme.smallFont + text: KI18n.i18nc("@info volume percentage", "%1%", root.mpv.volumeMax) + } } ToolButton { @@ -198,7 +225,7 @@ Item { icon.width: root.buttonSize icon.height: root.buttonSize display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus visible: !QtPotatoSettings.playlistToggleWithMouse ToolTip.text: text @@ -208,6 +235,8 @@ Item { } RowLayout { + spacing: Kirigami.Units.largeSpacing + LabelWithTooltip { text: root.mpv.mediaTitle font.pointSize: Kirigami.Theme.smallFont.pointSize diff --git a/src/qml/Header.qml b/src/qml/Header.qml index 38ec731..9e32d70 100644 --- a/src/qml/Header.qml +++ b/src/qml/Header.qml @@ -12,6 +12,13 @@ import org.kde.kirigami as Kirigami ToolBar { id: root + // Space activates the focused control before the window's playback action. + Keys.onShortcutOverride: event => { + if (event.key === Qt.Key_Space && event.modifiers === Qt.NoModifier) { + event.accepted = true + } + } + required property Actions actions required property MpvItem mpv required property TranslationService translator @@ -19,7 +26,7 @@ ToolBar { property bool shown: true property bool menuBarShown: true - readonly property bool isSmallSize: width < 600 + readonly property bool isSmallSize: width < Kirigami.Units.gridUnit * 34 readonly property int buttonSize: isSmallSize ? Kirigami.Units.iconSizes.small : Kirigami.Units.iconSizes.smallMedium position: ToolBar.Header @@ -27,29 +34,39 @@ ToolBar { RowLayout { width: parent.width + spacing: Kirigami.Units.mediumSpacing HamburgerMenu { actions: root.actions mpv: root.mpv visible: !root.menuBarShown + focusPolicy: Qt.StrongFocus icon.width: root.buttonSize icon.height: root.buttonSize } ToolButton { action: root.actions.openFile - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus icon.width: root.buttonSize icon.height: root.buttonSize display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay } ToolButton { action: root.actions.openUrl - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus icon.width: root.buttonSize icon.height: root.buttonSize display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay } ToolSeparator {} @@ -60,10 +77,15 @@ ToolBar { icon.name: "add-subtitle" icon.width: root.buttonSize icon.height: root.buttonSize - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus enabled: root.mpv.fileLoaded opacity: enabled ? 1.0 : 0.6 display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + Accessible.role: Accessible.ButtonMenu down: subtitleMenu.visible onPressed: subtitleMenu.open() @@ -89,10 +111,15 @@ ToolBar { icon.name: "audio-volume-high" icon.width: root.buttonSize icon.height: root.buttonSize - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus enabled: root.mpv.audioTracks.length > 0 opacity: enabled ? 1.0 : 0.6 display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + Accessible.role: Accessible.ButtonMenu down: audioMenu.visible onPressed: audioMenu.open() @@ -135,7 +162,7 @@ ToolBar { ToolButton { action: root.actions.cancelTranslation display: AbstractButton.IconOnly - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus ToolTip.text: text ToolTip.visible: hovered @@ -149,10 +176,14 @@ ToolBar { ToolButton { action: root.actions.configure - focusPolicy: Qt.NoFocus + focusPolicy: Qt.StrongFocus icon.width: root.buttonSize icon.height: root.buttonSize display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay } } diff --git a/src/qml/InputPopup.qml b/src/qml/InputPopup.qml index abc136e..5b8afcb 100644 --- a/src/qml/InputPopup.qml +++ b/src/qml/InputPopup.qml @@ -5,46 +5,83 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import org.kde.ki18n import org.kde.kirigami as Kirigami -// Single-line text popup, used for Open URL. -Popup { +Kirigami.PromptDialog { id: root property string lastText: "" property string placeholderText: "" property string buttonText: "" + property string errorText: "" + readonly property bool validUrl: isValidUrl(textField.text.trim()) signal submitted(text: string) + function isValidUrl(text: string): bool { + // Network URLs need both a scheme and a host. Reject whitespace, missing + // authorities, empty labels, and ports outside the TCP/UDP range. + const match = /^([a-z][a-z0-9+.-]*):\/\/([^/?#\s]+)(?:[/?#][^\s]*)?$/i.exec(text) + if (!match) return false + const authority = match[2].substring(match[2].lastIndexOf("@") + 1) + const hostPort = /^(\[[0-9a-f:.]+\]|[^:@\[\]]+)(?::([0-9]+))?$/i.exec(authority) + if (!hostPort || (hostPort[2] !== undefined && (+hostPort[2] < 1 || +hostPort[2] > 65535))) return false + const host = hostPort[1] + return host.startsWith("[") || host.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)) + } + + function submit() { + if (!validUrl) { + errorText = KI18n.i18nc("@info:usagetip", "Enter a URL with a scheme and a host, such as https://example.org/video.mkv.") + textField.forceActiveFocus() + return + } + submitted(textField.text.trim()) + close() + } + + title: KI18n.i18nc("@title:window", "Open URL") modal: true + standardButtons: Kirigami.Dialog.Cancel + customFooterActions: Kirigami.Action { + text: root.buttonText + icon.name: "document-open" + enabled: root.validUrl + onTriggered: root.submit() + } onOpened: { + errorText = "" textField.text = root.lastText - textField.forceActiveFocus(Qt.MouseFocusReason) + textField.forceActiveFocus(Qt.OtherFocusReason) textField.selectAll() } - contentItem: RowLayout { + ColumnLayout { + spacing: Kirigami.Units.smallSpacing + Layout.fillWidth: true + + Label { + text: KI18n.i18nc("@label:textbox", "URL:") + Accessible.ignored: true + } + TextField { id: textField placeholderText: root.placeholderText + Accessible.name: KI18n.i18nc("@label:textbox", "URL") inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.largeSpacing - onAccepted: submitButton.clicked() - Keys.onEscapePressed: root.close() + onAccepted: root.submit() + onTextEdited: root.errorText = "" } - Button { - id: submitButton - text: root.buttonText - enabled: textField.text.trim().length > 0 - Layout.rightMargin: Kirigami.Units.largeSpacing - onClicked: { - root.submitted(textField.text.trim()) - root.close() - } + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: root.errorText + visible: text.length > 0 } } } diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 4f0fb77..4883bfa 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -50,10 +50,10 @@ ApplicationWindow { visible: true title: video.mediaTitle || KI18n.i18nc("@title:window", "QtPotato") - width: 1000 - height: 600 - minimumWidth: 400 - minimumHeight: 200 + width: Kirigami.Units.gridUnit * 55 + height: Kirigami.Units.gridUnit * 33 + minimumWidth: Kirigami.Units.gridUnit * 23 + minimumHeight: Kirigami.Units.gridUnit * 11 color: Kirigami.Theme.backgroundColor onVisibilityChanged: visibility => { @@ -180,7 +180,7 @@ ApplicationWindow { if (!QtPotatoSettings.playlistToggleWithMouse) { return } - const atEdge = playlistPanel.edge === Qt.RightEdge ? x > width - 50 : x < 50 + const atEdge = playlistPanel.edge === Qt.RightEdge ? x > width - Kirigami.Units.gridUnit * 3 : x < Kirigami.Units.gridUnit * 3 playlistPanel.state = atEdge ? "visible" : "hidden" } onPointerLeft: footerItem.pointerLeft() @@ -266,11 +266,10 @@ ApplicationWindow { InputPopup { id: openUrlPopup - x: Kirigami.Units.largeSpacing - y: Kirigami.Units.largeSpacing - width: Math.min(appWindow.width * 0.9, 600) + title: KI18n.i18nc("@title:window", "Open URL") + preferredWidth: Kirigami.Units.gridUnit * 30 lastText: QtPotatoSettings.lastUrl - placeholderText: "https://" + placeholderText: "https://example.org/video.mkv" buttonText: KI18n.i18nc("@action:button", "Open") onSubmitted: text => { QtPotatoSettings.lastUrl = text diff --git a/src/qml/MpvVideo.qml b/src/qml/MpvVideo.qml index d7992e8..e11ed8b 100644 --- a/src/qml/MpvVideo.qml +++ b/src/qml/MpvVideo.qml @@ -30,6 +30,9 @@ MpvItem { setPropertyAsync("sub-outline-size", QtPotatoSettings.subtitleOutlineSize) } + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + Kirigami.Theme.inherit: false + loadUserConfig: QtPotatoSettings.loadMpvConfig // Set once the saved volume is applied; mpv reports its default volume before that. @@ -55,7 +58,7 @@ MpvItem { Rectangle { anchors.fill: parent - color: "black" + color: Kirigami.Theme.backgroundColor visible: !root.fileLoaded z: -1 } @@ -95,6 +98,7 @@ MpvItem { onExited: root.pointerLeft() onPressed: mouse => { + root.forceActiveFocus(Qt.MouseFocusReason) switch (mouse.button) { case Qt.LeftButton: root.actions.playPause.trigger() diff --git a/src/qml/SeekBar.qml b/src/qml/SeekBar.qml index 559eed5..ed6fd13 100644 --- a/src/qml/SeekBar.qml +++ b/src/qml/SeekBar.qml @@ -10,7 +10,7 @@ import QtQuick.Layouts import org.kde.kirigami as Kirigami import org.kde.ki18n -// Flat progress bar of Haruna's footer: click or drag to seek, hover for the time, +// Playback position: click or drag to seek, hover for the time, // chapter marks, right click for the chapter list, wheel to seek. Slider { id: root @@ -19,7 +19,7 @@ Slider { required property Actions actions readonly property alias chaptersPopupOpen: chaptersPopup.visible - readonly property bool showChapterMarkers: root.mpv.chapters.length * 20 < width + readonly property bool showChapterMarkers: root.mpv.chapters.length * Kirigami.Units.gridUnit < width // Playing state before a drag started; playback pauses while dragging. property bool pausedBeforeDrag: false @@ -47,91 +47,81 @@ Slider { return chapter ? `${formatTime(time)} - ${chapter}` : formatTime(time) } + Accessible.name: KI18n.i18nc("@label:slider", "Playback position") + Accessible.description: timeLabel(value) + stepSize: 1 + from: 0 to: Math.max(root.mpv.duration, 0) enabled: root.mpv.fileLoaded && root.mpv.duration > 0 - focusPolicy: Qt.NoFocus - leftPadding: 0 - rightPadding: 0 + focusPolicy: Qt.StrongFocus - handle: Item { visible: false } - - background: Rectangle { - id: progressBarBackground - - color: Kirigami.Theme.alternateBackgroundColor - scale: root.mirrored ? -1 : 1 - - Rectangle { - width: root.visualPosition * parent.width - height: parent.height - color: Kirigami.Theme.highlightColor + // Preserve the Slider's own arrow-key behavior when playback shortcuts exist. + Keys.onShortcutOverride: event => { + if (event.modifiers === Qt.NoModifier && [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down, Qt.Key_Home, Qt.Key_End].includes(event.key)) { + event.accepted = true } + } + Keys.onPressed: event => { + if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Home || event.key === Qt.Key_End)) { + value = event.key === Qt.Key_Home ? from : to + moved() + event.accepted = true + } + } - Repeater { - model: root.showChapterMarkers ? root.mpv.chapters : [] - delegate: Rectangle { - required property var modelData - x: modelData.time / root.mpv.duration * progressBarBackground.width - width: 2 - height: progressBarBackground.height - color: Kirigami.Theme.textColor + Repeater { + model: root.showChapterMarkers ? root.mpv.chapters : [] + delegate: Rectangle { + required property var modelData + x: root.leftPadding + (root.mirrored ? 1 - modelData.time / root.to : modelData.time / root.to) * root.availableWidth + anchors.verticalCenter: parent.verticalCenter + width: Kirigami.Units.smallSpacing / 2 + height: Kirigami.Units.smallSpacing + color: Kirigami.Theme.textColor + } + } + + ToolTip { + id: timeToolTip + parent: root + property real pointerX: 0 + + x: { + const windowX = root.mapToItem(root.Window.window.contentItem, 0, 0).x + return Math.max(Kirigami.Units.largeSpacing - windowX, + Math.min(root.Window.window.width - width - Kirigami.Units.largeSpacing - windowX, pointerX - width / 2)) + } + y: -height - Kirigami.Units.largeSpacing + width: Math.min(implicitWidth, root.Window.window.width - Kirigami.Units.largeSpacing * 2) + visible: (mouseArea.containsMouse || root.pressed) && root.mpv.duration > 0 + text: root.timeLabel(root.pressed ? root.value : pointerX / Math.max(1, root.width) * root.to) + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + // Left button is the slider's own. + acceptedButtons: Qt.MiddleButton | Qt.RightButton + onPositionChanged: timeToolTip.pointerX = mouseX + onClicked: mouse => { + if (mouse.button === Qt.MiddleButton) { + const time = mouseX / width * root.to + const next = root.mpv.chapters.findIndex(chapter => chapter.time > time) + if (next >= 0) { + root.mpv.chapter = next + } + } else if (mouse.button === Qt.RightButton && root.mpv.chapters.length > 0) { + chaptersPopup.x = Math.max(0, Math.min(root.width - chaptersPopup.width, mouseX - chaptersPopup.width / 2)) + chaptersPopup.open() } } - - Rectangle { - id: timeToolTip - - property real pointerX: 0 - - x: Math.max(0, Math.min(parent.width - width, pointerX - width / 2)) - y: -height - Kirigami.Units.largeSpacing - z: 10 - width: timeLabel.implicitWidth + Kirigami.Units.smallSpacing * 2 - height: timeLabel.implicitHeight + Kirigami.Units.smallSpacing * 2 - visible: (mouseArea.containsMouse || root.pressed) && root.mpv.duration > 0 - color: Kirigami.Theme.backgroundColor - radius: Kirigami.Units.cornerRadius - border.width: 1 - border.color: Qt.darker(Kirigami.Theme.backgroundColor, 1.2) - - Label { - id: timeLabel - anchors.centerIn: parent - text: { - const time = root.pressed - ? root.value - : timeToolTip.pointerX / Math.max(1, progressBarBackground.width) * root.to - return root.timeLabel(time) - } - } - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - // Left button is the slider's own. - acceptedButtons: Qt.MiddleButton | Qt.RightButton - onPositionChanged: timeToolTip.pointerX = mouseX - onClicked: mouse => { - if (mouse.button === Qt.MiddleButton) { - const time = mouseX / width * root.to - const next = root.mpv.chapters.findIndex(chapter => chapter.time > time) - if (next >= 0) { - root.mpv.chapter = next - } - } else if (mouse.button === Qt.RightButton && root.mpv.chapters.length > 0) { - chaptersPopup.x = Math.max(0, Math.min(root.width - chaptersPopup.width, mouseX - chaptersPopup.width / 2)) - chaptersPopup.open() - } - } - onWheel: wheel => { - if (wheel.angleDelta.y > 0) { - root.actions.seekForwardMedium.trigger() - } else if (wheel.angleDelta.y < 0) { - root.actions.seekBackwardMedium.trigger() - } + onWheel: wheel => { + if (wheel.angleDelta.y > 0) { + root.actions.seekForwardMedium.trigger() + } else if (wheel.angleDelta.y < 0) { + root.actions.seekBackwardMedium.trigger() } } } diff --git a/src/qml/ToolTipButton.qml b/src/qml/ToolTipButton.qml index 28c1640..424f059 100644 --- a/src/qml/ToolTipButton.qml +++ b/src/qml/ToolTipButton.qml @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick +import org.kde.ki18n import QtQuick.Controls import org.kde.kirigami as Kirigami @@ -9,10 +10,21 @@ import org.kde.kirigami as Kirigami ToolButton { id: root + // Space activates the focused control before the window's playback action. + Keys.onShortcutOverride: event => { + if (event.key === Qt.Key_Space && event.modifiers === Qt.NoModifier) { + event.accepted = true + } + } + required property string toolTipText icon.name: "documentinfo" - focusPolicy: Qt.NoFocus + text: KI18n.i18nc("@action:button", "Help") + display: AbstractButton.IconOnly + Accessible.name: text + Accessible.description: toolTipText + focusPolicy: Qt.StrongFocus checkable: true ToolTip { @@ -21,11 +33,8 @@ ToolButton { delay: root.checked ? 0 : Kirigami.Units.toolTipDelay timeout: -1 closePolicy: Popup.NoAutoClose - contentItem: Label { - text: root.toolTipText - wrapMode: Text.WordWrap - width: Math.min(implicitWidth, Kirigami.Units.gridUnit * 20) - } - width: Math.min(contentItem.implicitWidth, Kirigami.Units.gridUnit * 20) + leftPadding + rightPadding + text: root.toolTipText + x: (root.width - width) / 2 + width: Math.min(implicitWidth, Kirigami.Units.gridUnit * 20, root.Window.window.width - Kirigami.Units.largeSpacing * 2) } } diff --git a/src/qml/VolumeSlider.qml b/src/qml/VolumeSlider.qml index ca4e293..a130ae7 100644 --- a/src/qml/VolumeSlider.qml +++ b/src/qml/VolumeSlider.qml @@ -7,49 +7,41 @@ import QtQuick.Controls import org.kde.kirigami as Kirigami import org.kde.ki18n -// Flat volume bar with the value written on it, as in Haruna. +// Use the desktop style's handle and hover/focus feedback. Slider { id: root required property MpvItem mpv + Accessible.name: KI18n.i18nc("@label:slider", "Volume") + Accessible.description: root.mpv.mute ? KI18n.i18nc("@info volume muted", "Muted") : KI18n.i18nc("@info volume percentage", "%1%", Math.round(value)) from: 0 to: root.mpv.volumeMax - implicitWidth: 100 - implicitHeight: 25 + implicitWidth: Kirigami.Units.gridUnit * 5 wheelEnabled: true stepSize: QtPotatoSettings.volumeStep - focusPolicy: Qt.NoFocus - leftPadding: 0 - rightPadding: 0 + focusPolicy: Qt.StrongFocus - handle: Item { visible: false } - - background: Rectangle { - color: Kirigami.Theme.alternateBackgroundColor - scale: root.mirrored ? -1 : 1 - - Rectangle { - width: root.visualPosition * parent.width - height: parent.height - color: Kirigami.Theme.highlightColor + // Let the Slider consume navigation keys before window playback shortcuts. + Keys.onShortcutOverride: event => { + if (event.modifiers === Qt.NoModifier && [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down, Qt.Key_Home, Qt.Key_End].includes(event.key)) { + event.accepted = true + } + } + Keys.onPressed: event => { + if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Home || event.key === Qt.Key_End)) { + value = event.key === Qt.Key_Home ? from : to + moved() + event.accepted = true } } onMoved: root.mpv.volume = Math.round(value) - // Follow mpv, but not while the user drags (dragging replaces a plain binding). + // Follow mpv, but not while the user drags. Binding on value { value: root.mpv.volume when: !root.pressed restoreMode: Binding.RestoreNone } - - Label { - anchors.centerIn: root - text: root.mpv.mute ? KI18n.i18nc("@info volume muted", "Muted") : Math.round(root.value) - color: "#fff" - style: Text.Outline - styleColor: "#111" - } } -- 2.54.0