diff --git a/CMakeLists.txt b/CMakeLists.txt index 97d2e19..9ccc1e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,10 +29,6 @@ pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavut qcoro_enable_coroutines() -find_package(KF6KirigamiAddons 1.0) -set_package_properties(KF6KirigamiAddons PROPERTIES - TYPE RUNTIME - PURPOSE "FormCard settings and About pages") add_subdirectory(src) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6c82149..b08919a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -57,10 +57,42 @@ ecm_add_qml_module(qtpotato ) ecm_target_qml_sources(qtpotato SOURCES + qml/Actions.qml + qml/AudioMenu.qml + qml/AudioTracksMenu.qml + qml/FileMenu.qml + qml/Footer.qml + qml/GeneralSettings.qml + qml/HamburgerMenu.qml + qml/Header.qml + qml/HelpMenu.qml + qml/InputPopup.qml + qml/LabelWithTooltip.qml qml/Main.qml - qml/PlayerPage.qml - qml/SubtitlePopup.qml - qml/SettingsPage.qml + qml/MainMenuBar.qml + qml/MpvContextMenu.qml + qml/MpvVideo.qml + qml/Osd.qml + qml/PlaybackMenu.qml + qml/PlaybackSettings.qml + qml/Playlist.qml + qml/PlaylistItem.qml + qml/PlaylistSettings.qml + qml/RecentFilesMenu.qml + qml/ResizeHandler.qml + qml/SeekBar.qml + qml/SettingsBasePage.qml + qml/SettingsHeader.qml + qml/SettingsMenu.qml + qml/SettingsWindow.qml + qml/SubtitleTracksMenu.qml + qml/SubtitlesMenu.qml + qml/SubtitlesSettings.qml + qml/ToolTipButton.qml + qml/TranslationSettings.qml + qml/VideoMenu.qml + qml/ViewMenu.qml + qml/VolumeSlider.qml ) target_include_directories(qtpotato PRIVATE models mpris mpv playlist translate) diff --git a/src/main.cpp b/src/main.cpp index 4bcd332..dd9349c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -42,6 +42,9 @@ int main(int argc, char *argv[]) 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.setProgramLogo(u"multimedia-video-player"_s); + aboutData.setHomepage(u"https://github.com/vokhactri/QtPotato"_s); + aboutData.setBugAddress("https://github.com/vokhactri/QtPotato/issues"); aboutData.setOrganizationDomain("trivk.dev"); KAboutData::setApplicationData(aboutData); QApplication::setWindowIcon(QIcon::fromTheme(u"multimedia-video-player"_s)); diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index 89e7409..d110a41 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -327,11 +327,6 @@ void MpvItem::showOnlySubtitle(int id) setSecondarySubtitleId(0); } -void MpvItem::showText(const QString &text) -{ - commandAsync(QStringList{QStringLiteral("show-text"), text}); -} - bool MpvItem::loadUserConfig() const { return m_loadUserConfig; diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index 9cc5fa8..810ec9b 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -102,8 +102,6 @@ public: // Makes \a url the only subtitle shown, adding it first (named \a title) unless it is // already loaded. Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title); - // Shows a short message on mpv's on-screen display. - Q_INVOKABLE void showText(const QString &text); Q_SIGNALS: void currentUrlChanged(); diff --git a/src/qml/Actions.qml b/src/qml/Actions.qml new file mode 100644 index 0000000..3c9ca22 --- /dev/null +++ b/src/qml/Actions.qml @@ -0,0 +1,502 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +// Every command of the player, shared by the menus, the toolbar buttons and the +// keyboard shortcuts. Shortcuts follow Haruna's defaults. +Item { + id: root + + required property MpvItem mpv + required property PlaylistModel playlist + required property TranslationService translator + required property Osd osd + + // Off while a dialog has the keyboard, so typing there does not seek or pause. + property bool shortcutsEnabled: true + property bool fullscreen: false + + readonly property bool hasFile: mpv.fileLoaded + + signal openFileRequested() + // Open a known file, such as one from Recent Files. + signal fileOpenRequested(url: url) + signal openUrlRequested() + signal addSubtitleRequested() + signal settingsRequested(page: string) + signal fullscreenToggleRequested() + signal fullscreenExitRequested() + signal playlistToggleRequested() + signal menuBarToggleRequested() + signal headerToggleRequested() + signal contextMenuRequested() + signal translateRequested() + + function seekBy(seconds: real) { + mpv.seekBy(seconds) + } + + function changeVolume(steps: int) { + mpv.volume = mpv.volume + steps * QtPotatoSettings.volumeStep + osd.message(KI18n.i18nc("@info:osd", "Volume: %1", mpv.volume)) + } + + function changeSpeed(delta: real) { + mpv.speed = delta === 0 ? 1.0 : mpv.speed + delta + osd.message(KI18n.i18nc("@info:osd", "Playback speed: %1", Number(mpv.speed).toFixed(2))) + } + + function changeSubtitleDelay(seconds: real) { + mpv.subtitleDelay = mpv.subtitleDelay + seconds + osd.message(KI18n.i18nc("@info:osd", "Subtitle delay: %1 ms", Math.round(mpv.subtitleDelay * 1000))) + } + + function cycleTrack(tracks: var, currentId: int, step: int): int { + // Track ids with 0 ("off") first, as mpv's cycle commands do. + const ids = [0].concat(tracks.map(track => track.id)) + const index = Math.max(0, ids.indexOf(currentId)) + return ids[(index + step + ids.length) % ids.length] + } + + // The next or previous file, following the playback behavior setting. + function playNext(): bool { + return playlist.playNext(QtPotatoSettings.playbackBehavior === "RepeatPlaylist") + } + + function playPrevious(): bool { + return playlist.playPrevious(QtPotatoSettings.playbackBehavior === "RepeatPlaylist") + } + + property Action openFile: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Open File…") + icon.name: "folder-videos" + shortcut: StandardKey.Open + onTriggered: root.openFileRequested() + } + + property Action openUrl: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Open URL…") + icon.name: "internet-services" + shortcut: "Ctrl+Shift+O" + onTriggered: root.openUrlRequested() + } + + property Action quit: Action { + text: KI18n.i18nc("@action", "Quit") + icon.name: "application-exit" + shortcut: StandardKey.Quit + onTriggered: Qt.quit() + } + + property Action playPause: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Play/Pause") + icon.name: root.mpv.paused ? "media-playback-start" : "media-playback-pause" + shortcut: "Space" + onTriggered: root.mpv.togglePause() + } + + property Action stop: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Stop") + icon.name: "media-playback-stop" + shortcut: "Media Stop" + onTriggered: root.mpv.stop() + } + + property Action restart: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Restart Playback") + icon.name: "edit-reset" + shortcut: "F5" + onTriggered: root.mpv.seek(0) + } + + property Action playNextFile: Action { + enabled: root.shortcutsEnabled && root.playlist.count > 1 + text: KI18n.i18nc("@action", "Play Next") + icon.name: "media-skip-forward" + shortcut: "Shift+." + onTriggered: root.playNext() + } + + property Action playPreviousFile: Action { + enabled: root.shortcutsEnabled && root.playlist.count > 1 + text: KI18n.i18nc("@action", "Play Previous") + icon.name: "media-skip-backward" + shortcut: "Shift+," + onTriggered: root.playPrevious() + } + + property Action seekForwardSmall: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Small Step Forward") + shortcut: "Right" + onTriggered: root.seekBy(QtPotatoSettings.seekSmallStep) + } + + property Action seekBackwardSmall: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Small Step Backward") + shortcut: "Left" + onTriggered: root.seekBy(-QtPotatoSettings.seekSmallStep) + } + + property Action seekForwardMedium: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Medium Step Forward") + shortcut: "Shift+Right" + onTriggered: root.seekBy(QtPotatoSettings.seekMediumStep) + } + + property Action seekBackwardMedium: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Medium Step Backward") + shortcut: "Shift+Left" + onTriggered: root.seekBy(-QtPotatoSettings.seekMediumStep) + } + + property Action seekForwardBig: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Big Step Forward") + shortcut: "Up" + onTriggered: root.seekBy(QtPotatoSettings.seekBigStep) + } + + property Action seekBackwardBig: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek Big Step Backward") + shortcut: "Down" + onTriggered: root.seekBy(-QtPotatoSettings.seekBigStep) + } + + property Action seekNextSubtitle: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek To Next Subtitle") + shortcut: "Ctrl+Right" + onTriggered: root.mpv.commandAsync(["sub-seek", "1"]) + } + + property Action seekPreviousSubtitle: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Seek To Previous Subtitle") + shortcut: "Ctrl+Left" + onTriggered: root.mpv.commandAsync(["sub-seek", "-1"]) + } + + property Action seekNextChapter: Action { + enabled: root.shortcutsEnabled && root.mpv.chapters.length > 0 + text: KI18n.i18nc("@action", "Seek To Next Chapter") + shortcut: "PgUp" + onTriggered: root.mpv.commandAsync(["add", "chapter", "1"]) + } + + property Action seekPreviousChapter: Action { + enabled: root.shortcutsEnabled && root.mpv.chapters.length > 0 + text: KI18n.i18nc("@action", "Seek To Previous Chapter") + shortcut: "PgDown" + onTriggered: root.mpv.commandAsync(["add", "chapter", "-1"]) + } + + property Action frameStepForward: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Step Frame Forward") + shortcut: "." + onTriggered: root.mpv.frameStep(true) + } + + property Action frameStepBackward: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Step Frame Backward") + shortcut: "," + onTriggered: root.mpv.frameStep(false) + } + + property Action speedUp: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Increase Playback Speed") + shortcut: "]" + onTriggered: root.changeSpeed(0.1) + } + + property Action speedDown: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Decrease Playback Speed") + shortcut: "[" + onTriggered: root.changeSpeed(-0.1) + } + + property Action speedReset: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Reset Playback Speed") + shortcut: "Backspace" + onTriggered: root.changeSpeed(0) + } + + property Action mute: Action { + enabled: root.shortcutsEnabled + text: root.mpv.mute ? KI18n.i18nc("@action", "Unmute") : KI18n.i18nc("@action", "Mute") + icon.name: root.mpv.mute ? "player-volume" : "player-volume-muted" + shortcut: "M" + onTriggered: { + root.mpv.mute = !root.mpv.mute + root.osd.message(root.mpv.mute ? KI18n.i18nc("@info:osd", "Audio muted") : KI18n.i18nc("@info:osd", "Audio unmuted")) + } + } + + property Action volumeUp: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Volume Up") + icon.name: "audio-volume-high" + shortcut: "9" + onTriggered: root.changeVolume(1) + } + + property Action volumeDown: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Volume Down") + icon.name: "audio-volume-low" + shortcut: "0" + onTriggered: root.changeVolume(-1) + } + + property Action audioCycleUp: Action { + enabled: root.shortcutsEnabled && root.mpv.audioTracks.length > 1 + text: KI18n.i18nc("@action", "Cycle Up Audio Track") + shortcut: "Shift+3" + onTriggered: root.mpv.audioId = root.cycleTrack(root.mpv.audioTracks, root.mpv.audioId, 1) + } + + property Action audioCycleDown: Action { + enabled: root.shortcutsEnabled && root.mpv.audioTracks.length > 1 + text: KI18n.i18nc("@action", "Cycle Down Audio Track") + shortcut: "Shift+2" + onTriggered: root.mpv.audioId = root.cycleTrack(root.mpv.audioTracks, root.mpv.audioId, -1) + } + + property Action addSubtitle: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Add External Subtitle…") + icon.name: "add-subtitle" + shortcut: "Alt+S" + onTriggered: root.addSubtitleRequested() + } + + property Action subtitleToggle: Action { + enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 + text: KI18n.i18nc("@action", "Toggle Subtitles") + shortcut: "Ctrl+S" + onTriggered: { + const visible = root.mpv.getProperty("sub-visibility") + root.mpv.setPropertyAsync("sub-visibility", !visible) + root.mpv.setPropertyAsync("secondary-sub-visibility", !visible) + root.osd.message(visible ? KI18n.i18nc("@info:osd", "Subtitles hidden") : KI18n.i18nc("@info:osd", "Subtitles shown")) + } + } + + property Action subtitleCycleUp: Action { + enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 + text: KI18n.i18nc("@action", "Cycle Up Subtitle") + shortcut: "J" + onTriggered: root.mpv.subtitleId = root.cycleTrack(root.mpv.subtitleTracks, root.mpv.subtitleId, 1) + } + + property Action subtitleCycleDown: Action { + enabled: root.shortcutsEnabled && root.mpv.subtitleTracks.length > 0 + text: KI18n.i18nc("@action", "Cycle Down Subtitle") + shortcut: "Shift+J" + onTriggered: root.mpv.subtitleId = root.cycleTrack(root.mpv.subtitleTracks, root.mpv.subtitleId, -1) + } + + property Action subtitleEarlier: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Show Subtitles Earlier") + shortcut: "Z" + onTriggered: root.changeSubtitleDelay(-0.1) + } + + property Action subtitleLater: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Show Subtitles Later") + shortcut: "Shift+Z" + onTriggered: root.changeSubtitleDelay(0.1) + } + + property Action subtitleDelayReset: Action { + enabled: root.shortcutsEnabled && root.hasFile && root.mpv.subtitleDelay !== 0 + text: KI18n.i18nc("@action", "Reset Subtitle Delay") + onTriggered: root.changeSubtitleDelay(-root.mpv.subtitleDelay) + } + + property Action subtitleBigger: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Increase Subtitle Font Size") + shortcut: "Ctrl+Z" + onTriggered: { + QtPotatoSettings.subtitleFontSize = Math.min(150, QtPotatoSettings.subtitleFontSize + 2) + QtPotatoSettings.save() + root.osd.message(KI18n.i18nc("@info:osd", "Subtitle font size: %1", QtPotatoSettings.subtitleFontSize)) + } + } + + property Action subtitleSmaller: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Decrease Subtitle Font Size") + shortcut: "Ctrl+X" + onTriggered: { + QtPotatoSettings.subtitleFontSize = Math.max(20, QtPotatoSettings.subtitleFontSize - 2) + QtPotatoSettings.save() + root.osd.message(KI18n.i18nc("@info:osd", "Subtitle font size: %1", QtPotatoSettings.subtitleFontSize)) + } + } + + property Action translateSubtitle: Action { + enabled: root.shortcutsEnabled && root.hasFile && root.mpv.subtitleId > 0 && !root.translator.busy + text: KI18n.i18nc("@action", "Translate Subtitle into %1", QtPotatoSettings.targetLanguage) + icon.name: "translate" + shortcut: "Ctrl+T" + onTriggered: root.translateRequested() + } + + property Action cancelTranslation: Action { + enabled: root.translator.busy + text: KI18n.i18nc("@action", "Cancel Translation") + icon.name: "dialog-cancel" + onTriggered: root.translator.cancel() + } + + property Action screenshot: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Screenshot") + icon.name: "image-x-generic" + shortcut: "S" + onTriggered: { + root.mpv.screenshot() + root.osd.message(KI18n.i18nc("@info:osd", "Screenshot saved")) + } + } + + property Action toggleDeinterlacing: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Toggle Deinterlacing") + shortcut: "D" + onTriggered: { + const enabled = root.mpv.getProperty("deinterlace") === "yes" + root.mpv.setPropertyAsync("deinterlace", enabled ? "no" : "yes") + root.osd.message(enabled ? KI18n.i18nc("@info:osd", "Deinterlacing off") : KI18n.i18nc("@info:osd", "Deinterlacing on")) + } + } + + property Action zoomIn: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Zoom In") + icon.name: "zoom-in" + shortcut: StandardKey.ZoomIn + onTriggered: root.mpv.commandAsync(["add", "video-zoom", "0.1"]) + } + + property Action zoomOut: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Zoom Out") + icon.name: "zoom-out" + shortcut: StandardKey.ZoomOut + onTriggered: root.mpv.commandAsync(["add", "video-zoom", "-0.1"]) + } + + property Action zoomReset: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Reset Zoom") + icon.name: "zoom-original" + shortcut: "Ctrl+0" + onTriggered: root.mpv.setPropertyAsync("video-zoom", 0) + } + + property Action rotateClockwise: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Rotate Clockwise") + icon.name: "object-rotate-right" + shortcut: "Ctrl+R" + onTriggered: root.mpv.setPropertyAsync("video-rotate", (root.mpv.getProperty("video-rotate") + 90) % 360) + } + + property Action rotateCounterClockwise: Action { + enabled: root.shortcutsEnabled && root.hasFile + text: KI18n.i18nc("@action", "Rotate Counter Clockwise") + icon.name: "object-rotate-left" + shortcut: "Ctrl+E" + onTriggered: root.mpv.setPropertyAsync("video-rotate", (root.mpv.getProperty("video-rotate") + 270) % 360) + } + + property Action toggleFullscreen: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Toggle Fullscreen") + icon.name: "view-fullscreen" + shortcut: "F" + onTriggered: root.fullscreenToggleRequested() + } + + property Action exitFullscreen: Action { + // Only while full screen, so Esc still reaches everything else. + enabled: root.shortcutsEnabled && root.fullscreen + text: KI18n.i18nc("@action", "Exit Fullscreen") + shortcut: "Escape" + onTriggered: root.fullscreenExitRequested() + } + + property Action togglePlaylist: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Playlist") + icon.name: "view-media-playlist" + shortcut: "P" + onTriggered: root.playlistToggleRequested() + } + + property Action toggleMenuBar: Action { + enabled: root.shortcutsEnabled + text: QtPotatoSettings.showMenuBar ? KI18n.i18nc("@action", "Hide Menubar") : KI18n.i18nc("@action", "Show Menubar") + icon.name: "show-menu" + shortcut: "Ctrl+M" + onTriggered: root.menuBarToggleRequested() + } + + property Action toggleHeader: Action { + enabled: root.shortcutsEnabled + text: QtPotatoSettings.showHeader ? KI18n.i18nc("@action", "Hide Toolbar") : KI18n.i18nc("@action", "Show Toolbar") + shortcut: "Ctrl+H" + onTriggered: root.headerToggleRequested() + } + + property Action openContextMenu: Action { + enabled: root.shortcutsEnabled + text: KI18n.i18nc("@action", "Open Context Menu") + icon.name: "application-menu" + shortcut: "Menu" + onTriggered: root.contextMenuRequested() + } + + property Action configure: Action { + text: KI18n.i18nc("@action", "Settings") + icon.name: "configure" + shortcut: "Ctrl+Shift+," + onTriggered: root.settingsRequested("general") + } + + property Action about: Action { + text: KI18n.i18nc("@action", "About QtPotato") + icon.name: "help-about" + shortcut: "F1" + onTriggered: root.settingsRequested("about") + } + + property Action reportBug: Action { + text: KI18n.i18nc("@action", "Report Bug") + icon.name: "tools-report-bug" + onTriggered: Qt.openUrlExternally("https://github.com/vokhactri/QtPotato/issues") + } +} diff --git a/src/qml/AudioMenu.qml b/src/qml/AudioMenu.qml new file mode 100644 index 0000000..30b5e38 --- /dev/null +++ b/src/qml/AudioMenu.qml @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + required property MpvItem mpv + + title: KI18n.i18nc("@title:menu", "&Audio") + + AudioTracksMenu { + mpv: root.mpv + title: KI18n.i18nc("@title:menu", "&Track") + } + MenuSeparator {} + MenuItem { action: root.actions.mute } + MenuItem { action: root.actions.volumeUp } + MenuItem { action: root.actions.volumeDown } + MenuItem { action: root.actions.audioCycleUp } + MenuItem { action: root.actions.audioCycleDown } +} diff --git a/src/qml/AudioTracksMenu.qml b/src/qml/AudioTracksMenu.qml new file mode 100644 index 0000000..83ff3bc --- /dev/null +++ b/src/qml/AudioTracksMenu.qml @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +// Checkable list of audio tracks. +Menu { + id: root + + required property MpvItem mpv + + function trackLabel(track: var): string { + const lang = track.lang ? `[${track.lang}]` : "" + const description = [track.title, lang, track.codec].filter(part => part && part.length > 0).join(" ") + return KI18n.i18nc("@item:inmenu audio track number and description", "%1: %2", + track.id, description || KI18n.i18nc("@item:inmenu", "Untitled")) + } + + enabled: mpv.audioTracks.length > 0 + + Instantiator { + model: root.mpv.audioTracks + delegate: MenuItem { + required property var modelData + text: root.trackLabel(modelData) + checkable: true + checked: modelData.id === root.mpv.audioId + onTriggered: root.mpv.audioId = modelData.id + } + 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 new file mode 100644 index 0000000..34881fd --- /dev/null +++ b/src/qml/FileMenu.qml @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&File") + + MenuItem { action: root.actions.openFile } + MenuItem { action: root.actions.openUrl } + RecentFilesMenu { actions: root.actions } + MenuSeparator {} + MenuItem { action: root.actions.quit } +} diff --git a/src/qml/Footer.qml b/src/qml/Footer.qml new file mode 100644 index 0000000..c062bb1 --- /dev/null +++ b/src/qml/Footer.qml @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// Playback controls below the video. In the "floating" style, and in full screen, they +// sit over the video and show up when the pointer moves near the bottom. +Item { + id: root + + required property Actions actions + required property MpvVideo mpv + property bool fullscreen: false + // The window's header and menu bar are hidden, so the footer offers their menu. + property bool showMenuButton: false + + 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 int buttonSize: isSmallSize ? Kirigami.Units.iconSizes.small : Kirigami.Units.iconSizes.smallMedium + readonly property bool busy: footer.hovered || hamburgerMenu.isOpen || seekBar.chaptersPopupOpen || seekBar.pressed + property bool shown: true + + implicitHeight: isFloating + ? footer.height + Kirigami.Units.largeSpacing * (isSmallSize ? 1 : 2) + : footer.height + + function formatTime(totalSeconds: real): string { + return seekBar.formatTime(totalSeconds) + } + + // Called when the pointer moves over the video. + function pointerMoved(y: real) { + if (root.isDocked) { + return + } + const nearBottom = y > root.mpv.height - footer.height * 2 + if (root.isFloating) { + // Every movement reveals the floating footer for a moment; it stays while + // the pointer is near it. + root.shown = true + if (nearBottom) { + hideTimer.stop() + } else { + hideTimer.restart() + } + return + } + // Docked style in full screen: only near the bottom edge. + root.shown = nearBottom || root.busy + } + + function pointerLeft() { + if (!root.isDocked) { + hideTimer.restart() + } + } + + onIsDockedChanged: { + hideTimer.stop() + shown = isDocked || !mpv.fileLoaded + } + + Connections { + target: root.mpv + function onFileLoadedChanged() { + if (!root.mpv.fileLoaded) { + root.shown = true + } + } + } + + Timer { + id: hideTimer + interval: 1000 + onTriggered: { + if (root.isDocked || root.busy || !root.mpv.fileLoaded) { + return + } + root.shown = false + } + } + + ToolBar { + id: footer + + x: (root.width - width) / 2 + width: root.isFloating + ? Math.min(root.width - Kirigami.Units.largeSpacing * (root.isSmallSize ? 2 : 4), 900) + : root.width + state: root.shown ? "visible" : "hidden" + padding: Kirigami.Units.smallSpacing + position: ToolBar.Footer + hoverEnabled: true + + background: Rectangle { + color: Qt.alpha(Kirigami.Theme.backgroundColor, root.isDocked ? 1.0 : 0.8) + radius: root.isFloating ? Kirigami.Units.cornerRadius : 0 + } + + onHoveredChanged: { + if (!hovered) { + root.pointerLeft() + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + spacing: root.isSmallSize ? 1 : Kirigami.Units.smallSpacing + + HamburgerMenu { + id: hamburgerMenu + actions: root.actions + mpv: root.mpv + inFooter: true + visible: root.showMenuButton + icon.width: root.buttonSize + icon.height: root.buttonSize + } + + ToolButton { + id: playPauseButton + action: root.actions.playPause + display: AbstractButton.IconOnly + icon.width: root.buttonSize + icon.height: root.buttonSize + focusPolicy: Qt.NoFocus + + ToolTip.text: root.mpv.paused ? KI18n.i18nc("@info:tooltip", "Start playback") : KI18n.i18nc("@info:tooltip", "Pause playback") + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + + ToolButton { + action: root.actions.playPreviousFile + icon.name: footer.LayoutMirroring.enabled ? "media-skip-forward" : "media-skip-backward" + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + + ToolTip.text: KI18n.i18nc("@info:tooltip", "Play previous file") + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + + ToolButton { + action: root.actions.playNextFile + icon.name: footer.LayoutMirroring.enabled ? "media-skip-backward" : "media-skip-forward" + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + + ToolTip.text: KI18n.i18nc("@info:tooltip", "Play next file") + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + + SeekBar { + id: seekBar + mpv: root.mpv + actions: root.actions + Layout.fillWidth: true + Layout.preferredHeight: 20 + } + + ToolButton { + action: root.actions.mute + icon.name: root.mpv.mute || root.mpv.volume === 0 ? "player-volume-muted" : "player-volume" + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + + ToolTip.text: root.actions.mute.text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + + VolumeSlider { + mpv: root.mpv + Layout.preferredWidth: root.isSmallSize ? 60 : 100 + Layout.preferredHeight: 20 + } + + ToolButton { + action: root.actions.togglePlaylist + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + visible: !QtPotatoSettings.playlistToggleWithMouse + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + RowLayout { + LabelWithTooltip { + text: root.mpv.mediaTitle + font.pointSize: Kirigami.Theme.smallFont.pointSize + elide: Text.ElideRight + Layout.fillWidth: true + } + + LabelWithTooltip { + id: timeInfo + text: "%1 / %2".arg(root.formatTime(root.mpv.position)).arg(root.formatTime(root.mpv.duration)) + font.pointSize: Kirigami.Theme.smallFont.pointSize + font.features: { "tnum": 1 } + toolTipText: KI18n.i18nc("@info:tooltip", "Remaining: %1", root.formatTime(root.mpv.duration - root.mpv.position)) + alwaysShowToolTip: true + horizontalAlignment: Qt.AlignHCenter + Layout.minimumWidth: textMetrics.width + + TextMetrics { + id: textMetrics + font: timeInfo.font + text: "000:00:00 / 000:00:00" + } + } + } + } + + states: [ + State { + name: "hidden" + PropertyChanges { + footer.y: root.height + footer.visible: false + } + }, + State { + name: "visible" + PropertyChanges { + footer.y: 0 + footer.visible: true + } + } + ] + + transitions: [ + Transition { + from: "visible" + to: "hidden" + SequentialAnimation { + NumberAnimation { + target: footer + property: "y" + duration: Kirigami.Units.shortDuration + easing.type: Easing.Linear + } + PropertyAction { + target: footer + property: "visible" + value: false + } + } + }, + Transition { + from: "hidden" + to: "visible" + SequentialAnimation { + PropertyAction { + target: footer + property: "visible" + value: true + } + NumberAnimation { + target: footer + property: "y" + duration: Kirigami.Units.shortDuration + easing.type: Easing.Linear + } + } + } + ] + } +} diff --git a/src/qml/GeneralSettings.qml b/src/qml/GeneralSettings.qml new file mode 100644 index 0000000..fcda8cb --- /dev/null +++ b/src/qml/GeneralSettings.qml @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +SettingsBasePage { + id: root + + required property MpvItem mpv + + GridLayout { + columns: 2 + + 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 { + ComboBox { + textRole: "text" + valueRole: "value" + model: [ + { text: KI18n.i18nc("@item:inlistbox footer style", "Default"), value: "default" }, + { text: KI18n.i18nc("@item:inlistbox footer style", "Floating"), value: "floating" } + ] + Component.onCompleted: currentIndex = indexOfValue(QtPotatoSettings.footerStyle) + onActivated: { + QtPotatoSettings.footerStyle = currentValue + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "Default: the controls sit below the video.
Floating: the controls float over the video and hide when the mouse rests.") + } + } + + Item { Layout.preferredWidth: 1 } + + CheckBox { + text: KI18n.i18nc("@option:check", "Show menu bar") + checked: QtPotatoSettings.showMenuBar + onClicked: { + QtPotatoSettings.showMenuBar = checked + QtPotatoSettings.save() + } + } + + Item { Layout.preferredWidth: 1 } + + CheckBox { + text: KI18n.i18nc("@option:check", "Show toolbar") + checked: QtPotatoSettings.showHeader + onClicked: { + QtPotatoSettings.showHeader = checked + QtPotatoSettings.save() + } + } + + Label { + text: KI18n.i18nc("@label:spinbox", "Maximum recent files") + Layout.alignment: Qt.AlignRight + } + + RowLayout { + SpinBox { + from: 0 + to: 50 + value: QtPotatoSettings.maxRecentFiles + onValueModified: { + QtPotatoSettings.maxRecentFiles = value + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "The number of recent files to list. Enter 0 (zero) to hide the list.") + } + } + + SettingsHeader { + text: KI18n.i18nc("@title", "mpv") + Layout.columnSpan: 2 + Layout.fillWidth: true + } + + Item { Layout.preferredWidth: 1 } + + RowLayout { + CheckBox { + text: KI18n.i18nc("@option:check", "Use mpv.conf") + checked: QtPotatoSettings.loadMpvConfig + onClicked: { + QtPotatoSettings.loadMpvConfig = checked + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "Reads mpv.conf from %1, like the mpv player does. Changes apply the next time QtPotato starts. The subtitle size and outline from the Subtitles page take precedence.", root.mpv.userConfigDir) + } + } + + Item { Layout.preferredWidth: 1 } + + Label { + text: root.mpv.loadedUserConfigFiles.length > 0 + ? KI18n.i18nc("@info", "Loaded: %1", root.mpv.loadedUserConfigFiles.join(", ")) + : (QtPotatoSettings.loadMpvConfig + ? KI18n.i18nc("@info", "No mpv.conf found") + : KI18n.i18nc("@info", "mpv.conf is not used")) + wrapMode: Text.WordWrap + opacity: 0.7 + 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(", ")) + wrapMode: Text.WordWrap + opacity: 0.7 + Layout.fillWidth: true + } + } +} diff --git a/src/qml/HamburgerMenu.qml b/src/qml/HamburgerMenu.qml new file mode 100644 index 0000000..6591b49 --- /dev/null +++ b/src/qml/HamburgerMenu.qml @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// The application menu button, used when the menu bar is hidden. +ToolButton { + id: root + + required property Actions actions + required property MpvItem mpv + // In the footer the menu opens upwards and also offers what the header would. + property bool inFooter: false + readonly property alias isOpen: menu.visible + + icon.name: "application-menu" + text: KI18n.i18nc("@action:button", "Main Menu") + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + down: menu.visible + // The menu is modal, so a click outside it (also on this button) only closes it. + onPressed: menu.open() + + ToolTip.text: text + ToolTip.visible: hovered && !menu.visible + ToolTip.delay: Kirigami.Units.toolTipDelay + + Menu { + id: menu + + y: root.inFooter ? -height - Kirigami.Units.largeSpacing : root.height + Kirigami.Units.largeSpacing + modal: true + dim: false + + MenuItem { + action: root.actions.openFile + visible: root.inFooter + height: visible ? implicitHeight : 0 + } + MenuItem { + action: root.actions.openUrl + visible: root.inFooter + height: visible ? implicitHeight : 0 + } + RecentFilesMenu { actions: root.actions } + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: true + title: KI18n.i18nc("@title:menu", "Primary Subtitle") + } + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: false + title: KI18n.i18nc("@title:menu", "Secondary Subtitle") + } + AudioTracksMenu { + mpv: root.mpv + title: KI18n.i18nc("@title:menu", "Audio Track") + } + MenuSeparator {} + MenuItem { action: root.actions.translateSubtitle } + MenuItem { action: root.actions.toggleFullscreen } + MenuItem { action: root.actions.togglePlaylist } + MenuItem { action: root.actions.screenshot } + MenuSeparator {} + MenuItem { + action: root.actions.configure + visible: root.inFooter + height: visible ? implicitHeight : 0 + } + MenuItem { action: root.actions.toggleMenuBar } + MenuItem { action: root.actions.about } + MenuSeparator {} + Menu { + title: KI18n.i18nc("@title:menu", "&More") + FileMenu { actions: root.actions } + ViewMenu { actions: root.actions } + PlaybackMenu { actions: root.actions } + VideoMenu { actions: root.actions } + SubtitlesMenu { + actions: root.actions + mpv: root.mpv + } + AudioMenu { + actions: root.actions + mpv: root.mpv + } + SettingsMenu { actions: root.actions } + HelpMenu { actions: root.actions } + } + } +} diff --git a/src/qml/Header.qml b/src/qml/Header.qml new file mode 100644 index 0000000..f75eeae --- /dev/null +++ b/src/qml/Header.qml @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// Toolbar above the video: open buttons, track menus, translation progress, settings. +ToolBar { + id: root + + required property Actions actions + required property MpvItem mpv + required property TranslationService translator + // False in full screen and when the user hides the toolbar. + property bool shown: true + property bool menuBarShown: true + + readonly property bool isSmallSize: width < 600 + readonly property int buttonSize: isSmallSize ? Kirigami.Units.iconSizes.small : Kirigami.Units.iconSizes.smallMedium + + position: ToolBar.Header + state: shown ? "visible" : "hidden" + + RowLayout { + width: parent.width + + HamburgerMenu { + actions: root.actions + mpv: root.mpv + visible: !root.menuBarShown + icon.width: root.buttonSize + icon.height: root.buttonSize + } + + ToolButton { + action: root.actions.openFile + focusPolicy: Qt.NoFocus + icon.width: root.buttonSize + icon.height: root.buttonSize + display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + } + + ToolButton { + action: root.actions.openUrl + focusPolicy: Qt.NoFocus + icon.width: root.buttonSize + icon.height: root.buttonSize + display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + } + + ToolSeparator {} + + ToolButton { + id: subtitleButton + text: KI18n.i18nc("@action:intoolbar", "Subtitles") + icon.name: "add-subtitle" + icon.width: root.buttonSize + icon.height: root.buttonSize + focusPolicy: Qt.NoFocus + enabled: root.mpv.fileLoaded + opacity: enabled ? 1.0 : 0.6 + display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + down: subtitleMenu.visible + onPressed: subtitleMenu.open() + + SubtitleTracksMenu { + id: subtitleMenu + mpv: root.mpv + isPrimary: true + y: parent.height + modal: true + dim: false + // The track list is empty for files without subtitles, but a file can + // still be added. + enabled: true + + MenuSeparator {} + MenuItem { action: root.actions.translateSubtitle } + MenuItem { action: root.actions.addSubtitle } + } + } + + ToolButton { + text: KI18n.i18nc("@action:intoolbar", "Audio") + icon.name: "audio-volume-high" + icon.width: root.buttonSize + icon.height: root.buttonSize + focusPolicy: Qt.NoFocus + enabled: root.mpv.audioTracks.length > 0 + opacity: enabled ? 1.0 : 0.6 + display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + down: audioMenu.visible + onPressed: audioMenu.open() + + AudioTracksMenu { + id: audioMenu + mpv: root.mpv + y: parent.height + modal: true + dim: false + } + } + + // Translation progress, with a way to stop it. + RowLayout { + visible: root.translator.busy + spacing: Kirigami.Units.smallSpacing + Layout.leftMargin: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "translate" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + } + + ProgressBar { + from: 0 + to: 1 + value: root.translator.progress + indeterminate: root.translator.progress === 0 + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + } + + Label { + text: root.translator.statusText + elide: Text.ElideRight + visible: !root.isSmallSize + Layout.maximumWidth: Kirigami.Units.gridUnit * 14 + } + + ToolButton { + action: root.actions.cancelTranslation + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + Item { + Layout.fillWidth: true + } + + ToolButton { + action: root.actions.configure + focusPolicy: Qt.NoFocus + icon.width: root.buttonSize + icon.height: root.buttonSize + display: root.isSmallSize ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + } + } + + states: [ + State { + name: "hidden" + PropertyChanges { + root.height: 0 + root.opacity: 0 + root.visible: false + } + }, + State { + name: "visible" + PropertyChanges { + root.height: root.implicitHeight + root.opacity: 1 + root.visible: true + } + } + ] + + transitions: [ + Transition { + from: "visible" + to: "hidden" + SequentialAnimation { + ParallelAnimation { + NumberAnimation { + target: root + property: "opacity" + duration: Kirigami.Units.veryShortDuration + easing.type: Easing.Linear + } + NumberAnimation { + target: root + property: "height" + duration: Kirigami.Units.veryShortDuration + easing.type: Easing.Linear + } + } + PropertyAction { + target: root + property: "visible" + value: false + } + } + }, + Transition { + from: "hidden" + to: "visible" + SequentialAnimation { + PropertyAction { + target: root + property: "visible" + value: true + } + ParallelAnimation { + NumberAnimation { + target: root + property: "height" + duration: Kirigami.Units.veryShortDuration + easing.type: Easing.Linear + } + NumberAnimation { + target: root + property: "opacity" + duration: Kirigami.Units.veryShortDuration + easing.type: Easing.Linear + } + } + } + } + ] +} diff --git a/src/qml/HelpMenu.qml b/src/qml/HelpMenu.qml new file mode 100644 index 0000000..c46449c --- /dev/null +++ b/src/qml/HelpMenu.qml @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&Help") + + MenuItem { action: root.actions.about } + MenuItem { action: root.actions.reportBug } +} diff --git a/src/qml/InputPopup.qml b/src/qml/InputPopup.qml new file mode 100644 index 0000000..abc136e --- /dev/null +++ b/src/qml/InputPopup.qml @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami + +// Single-line text popup, used for Open URL. +Popup { + id: root + + property string lastText: "" + property string placeholderText: "" + property string buttonText: "" + + signal submitted(text: string) + + modal: true + + onOpened: { + textField.text = root.lastText + textField.forceActiveFocus(Qt.MouseFocusReason) + textField.selectAll() + } + + contentItem: RowLayout { + TextField { + id: textField + placeholderText: root.placeholderText + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + onAccepted: submitButton.clicked() + Keys.onEscapePressed: root.close() + } + + 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() + } + } + } +} diff --git a/src/qml/LabelWithTooltip.qml b/src/qml/LabelWithTooltip.qml new file mode 100644 index 0000000..3e4a658 --- /dev/null +++ b/src/qml/LabelWithTooltip.qml @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.kirigami as Kirigami + +// A label that shows its full text in a tooltip when it is cut off. +Label { + id: root + + property string toolTipText + property bool alwaysShowToolTip: false + + ToolTip { + visible: hoverHandler.hovered && (root.alwaysShowToolTip || root.truncated) + text: root.toolTipText || root.text + delay: Kirigami.Units.toolTipDelay + } + + HoverHandler { + id: hoverHandler + } +} diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 334e199..174ffe8 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -1,44 +1,315 @@ -import QtQuick -import org.kde.kirigami as Kirigami -import org.kde.kirigamiaddons.formcard as FormCard +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later -Kirigami.ApplicationWindow { - id: root +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import org.kde.ki18n +import org.kde.kirigami as Kirigami +import org.kde.config as KConfig + +// Main window, laid out like Haruna: menu bar, toolbar, video with the playlist at +// its edge, and the playback controls below. +ApplicationWindow { + id: appWindow // 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 + property int previousVisibility: Window.Windowed + readonly property bool isFullScreen: visibility === Window.FullScreen - title: player.mediaTitle.length > 0 ? player.mediaTitle : "QtPotato" - - pageStack.initialPage: PlayerPage { - id: player - initialUrl: root.initialUrl - fullScreen: root.visibility === Window.FullScreen - onAboutRequested: root.pageStack.layers.push(aboutPage) - onSettingsRequested: root.pageStack.layers.push(settingsPage) - onFullScreenToggled: root.visibility = root.visibility === Window.FullScreen ? Window.Windowed : Window.FullScreen + function openFile(url: url) { + playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings) } - pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar - pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn - - onClosing: QtPotatoSettings.save() - - Component { - id: settingsPage - SettingsPage { - translator: player.translator - mpvItem: player.mpvItem + function toggleFullScreen() { + if (isFullScreen) { + exitFullScreen() + } else { + showFullScreen() } } - Component { - id: aboutPage - FormCard.AboutPage {} + function exitFullScreen() { + if (previousVisibility === Window.Maximized) { + showMaximized() + } else { + showNormal() + } + } + + function translateCurrentSubtitle() { + const track = video.subtitleTracks.find(track => track.id === video.subtitleId) + if (track) { + translationService.translate(video.currentUrl, track, video.mediaTitle) + } + } + + visible: true + title: video.mediaTitle || KI18n.i18nc("@title:window", "QtPotato") + width: 1000 + height: 600 + minimumWidth: 400 + minimumHeight: 200 + color: Kirigami.Theme.backgroundColor + + onVisibilityChanged: visibility => { + // Restored when leaving full screen. + if (visibility !== Window.FullScreen) { + previousVisibility = visibility + } + } + + onClosing: QtPotatoSettings.save() + + Component.onCompleted: { + if (initialUrl.toString().length > 0) { + openFile(initialUrl) + } + } + + KConfig.WindowStateSaver { + configGroupName: "MainWindow" + } + + menuBar: MainMenuBar { + actions: appActions + mpv: video + visible: !appWindow.isFullScreen && QtPotatoSettings.showMenuBar + } + + header: Header { + id: headerItem + actions: appActions + mpv: video + translator: translationService + shown: !appWindow.isFullScreen && QtPotatoSettings.showHeader + menuBarShown: appWindow.menuBar.visible + } + + Actions { + id: appActions + mpv: video + playlist: playlistModel + translator: translationService + osd: osdItem + fullscreen: appWindow.isFullScreen + shortcutsEnabled: !fileDialog.visible && !subtitleDialog.visible && !addToPlaylistDialog.visible && !openUrlPopup.visible + + onOpenFileRequested: fileDialog.open() + onFileOpenRequested: url => appWindow.openFile(url) + onOpenUrlRequested: openUrlPopup.open() + onAddSubtitleRequested: subtitleDialog.open() + onSettingsRequested: page => settingsWindow.openPage(page) + onFullscreenToggleRequested: appWindow.toggleFullScreen() + onFullscreenExitRequested: appWindow.exitFullScreen() + onPlaylistToggleRequested: playlistPanel.toggle() + onContextMenuRequested: contextMenu.popup() + onTranslateRequested: appWindow.translateCurrentSubtitle() + onMenuBarToggleRequested: { + QtPotatoSettings.showMenuBar = !QtPotatoSettings.showMenuBar + QtPotatoSettings.save() + } + onHeaderToggleRequested: { + QtPotatoSettings.showHeader = !QtPotatoSettings.showHeader + QtPotatoSettings.save() + } + } + + PlaylistModel { + id: playlistModel + onPlayRequested: url => { + video.loadFile(url) + RecentFilesModel.add(url) + } + } + + Binding { + target: RecentFilesModel + property: "maxCount" + value: QtPotatoSettings.maxRecentFiles + } + + MprisItem { + mpvItem: video + playlist: playlistModel + window: appWindow + onRaiseRequested: { + appWindow.show() + appWindow.raise() + appWindow.requestActivate() + } + onOpenUriRequested: uri => appWindow.openFile(uri) + onNextRequested: appActions.playNext() + onPreviousRequested: appActions.playPrevious() + } + + TranslationService { + id: translationService + onTranslated: (subtitleUrl, fromCache) => { + video.showTranslatedSubtitleFile(subtitleUrl, + KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)) + osdItem.message(fromCache + ? KI18n.i18nc("@info:osd", "Loaded the saved translation") + : KI18n.i18nc("@info:osd", "Translation ready")) + } + } + + MpvVideo { + id: video + + actions: appActions + fullscreen: appWindow.isFullScreen + + readonly property bool playlistBeside: playlistPanel.visible && !QtPotatoSettings.playlistOverlayVideo + + anchors.top: parent.top + anchors.left: playlistBeside && playlistPanel.edge === Qt.LeftEdge ? playlistPanel.right : parent.left + anchors.right: playlistBeside && playlistPanel.edge === Qt.RightEdge ? playlistPanel.left : parent.right + height: parent.height - (footerItem.isDocked ? footerItem.height : 0) + + onPointerMoved: (x, y) => { + footerItem.pointerMoved(y) + if (!QtPotatoSettings.playlistToggleWithMouse) { + return + } + const atEdge = playlistPanel.edge === Qt.RightEdge ? x > width - 50 : x < 50 + playlistPanel.state = atEdge ? "visible" : "hidden" + } + onPointerLeft: footerItem.pointerLeft() + onFilesDropped: (urls, append) => { + if (append) { + playlistModel.append(urls) + } else if (urls.length === 1) { + appWindow.openFile(urls[0]) + } else { + playlistModel.replace(urls) + } + } + onSubtitleDropped: url => video.addSubtitleFile(url) + + onEndOfFile: { + switch (QtPotatoSettings.playbackBehavior) { + case "RepeatItem": + video.seek(0) + video.paused = false + break + case "StopAfterItem": + break + default: + appActions.playNext() + } + } + + // A translation belongs to the file it was started for. + onCurrentUrlChanged: translationService.cancel() + + Osd { + id: osdItem + maxWidth: video.width + } + } + + Playlist { + id: playlistPanel + playlist: playlistModel + mpv: video + mainWindowWidth: appWindow.width + fullscreen: appWindow.isFullScreen + height: video.height + onAddFilesRequested: addToPlaylistDialog.open() + } + + Footer { + id: footerItem + actions: appActions + mpv: video + fullscreen: appWindow.isFullScreen + showMenuButton: !headerItem.visible && !appWindow.menuBar.visible + width: parent.width + anchors.bottom: parent.bottom + } + + Kirigami.InlineMessage { + width: Math.min(Kirigami.Units.gridUnit * 40, appWindow.width * 0.8) + anchors.centerIn: parent + type: Kirigami.MessageType.Error + text: translationService.errorText + visible: text.length > 0 + showCloseButton: true + onVisibleChanged: if (!visible) translationService.clearError() + actions: Kirigami.Action { + icon.name: "configure" + text: KI18n.i18nc("@action:button", "Translation Settings") + onTriggered: settingsWindow.openPage("translation") + } + } + + MpvContextMenu { + id: contextMenu + actions: appActions + mpv: video + } + + SettingsWindow { + id: settingsWindow + mpv: video + translator: translationService + } + + InputPopup { + id: openUrlPopup + x: Kirigami.Units.largeSpacing + y: Kirigami.Units.largeSpacing + width: Math.min(appWindow.width * 0.9, 600) + lastText: QtPotatoSettings.lastUrl + placeholderText: "https://" + buttonText: KI18n.i18nc("@action:button", "Open") + onSubmitted: text => { + QtPotatoSettings.lastUrl = text + QtPotatoSettings.save() + appWindow.openFile(text) + } + } + + FileDialog { + id: fileDialog + title: KI18n.i18nc("@title:window", "Select File") + currentFolder: QtPotatoSettings.fileDialogLastLocation + fileMode: FileDialog.OpenFile + 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: { + appWindow.openFile(selectedFile) + QtPotatoSettings.fileDialogLastLocation = currentFolder + QtPotatoSettings.save() + } + } + + FileDialog { + id: addToPlaylistDialog + title: KI18n.i18nc("@title:window", "Add to Playlist") + currentFolder: QtPotatoSettings.fileDialogLastLocation + fileMode: FileDialog.OpenFiles + onAccepted: playlistModel.append(selectedFiles) + } + + FileDialog { + id: subtitleDialog + title: KI18n.i18nc("@title:window", "Select Subtitles File") + currentFolder: { + const url = video.currentUrl.toString() + return url.substring(0, url.lastIndexOf("/")) + } + fileMode: FileDialog.OpenFile + nameFilters: [ + KI18n.i18nc("@item:inlistbox file filter", "Subtitle files (*.srt *.ass *.ssa *.vtt *.sub *.sup)"), + KI18n.i18nc("@item:inlistbox file filter", "All files (*)") + ] + onAccepted: video.addSubtitleFile(selectedFile) } } diff --git a/src/qml/MainMenuBar.qml b/src/qml/MainMenuBar.qml new file mode 100644 index 0000000..e6f2206 --- /dev/null +++ b/src/qml/MainMenuBar.qml @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// 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 + +MenuBar { + id: root + + required property Actions actions + required property MpvItem mpv + + hoverEnabled: true + Kirigami.Theme.colorSet: Kirigami.Theme.Header + + FileMenu { actions: root.actions } + ViewMenu { actions: root.actions } + PlaybackMenu { actions: root.actions } + VideoMenu { actions: root.actions } + SubtitlesMenu { + actions: root.actions + mpv: root.mpv + } + AudioMenu { + actions: root.actions + mpv: root.mpv + } + SettingsMenu { actions: root.actions } + HelpMenu { actions: root.actions } +} diff --git a/src/qml/MpvContextMenu.qml b/src/qml/MpvContextMenu.qml new file mode 100644 index 0000000..b1cc092 --- /dev/null +++ b/src/qml/MpvContextMenu.qml @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +// Right click menu of the video. +Menu { + id: root + + required property Actions actions + required property MpvItem mpv + + MenuItem { action: root.actions.playPause } + MenuItem { action: root.actions.playNextFile } + MenuItem { action: root.actions.playPreviousFile } + Menu { + title: KI18n.i18nc("@title:menu", "Playback Speed") + MenuItem { action: root.actions.speedUp } + MenuItem { action: root.actions.speedDown } + MenuItem { action: root.actions.speedReset } + } + MenuSeparator {} + MenuItem { action: root.actions.openFile } + MenuItem { action: root.actions.openUrl } + MenuSeparator {} + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: true + title: KI18n.i18nc("@title:menu", "Primary Subtitle") + } + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: false + title: KI18n.i18nc("@title:menu", "Secondary Subtitle") + } + AudioTracksMenu { + mpv: root.mpv + title: KI18n.i18nc("@title:menu", "Audio Track") + } + MenuItem { action: root.actions.translateSubtitle } + MenuSeparator {} + VideoMenu { actions: root.actions } + SubtitlesMenu { + actions: root.actions + mpv: root.mpv + } + AudioMenu { + actions: root.actions + mpv: root.mpv + } + MenuSeparator {} + MenuItem { action: root.actions.toggleFullscreen } + MenuItem { action: root.actions.togglePlaylist } + MenuItem { action: root.actions.toggleMenuBar } + MenuItem { action: root.actions.toggleHeader } + MenuSeparator {} + MenuItem { action: root.actions.configure } +} diff --git a/src/qml/MpvVideo.qml b/src/qml/MpvVideo.qml new file mode 100644 index 0000000..19a10c1 --- /dev/null +++ b/src/qml/MpvVideo.qml @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// The video, with Haruna's default mouse bindings and drag and drop. +MpvItem { + id: root + + required property Actions actions + property bool fullscreen: false + + signal pointerMoved(x: real, y: real) + signal pointerLeft() + // Files dropped on the top part are queued; on the bottom part they play now. + signal filesDropped(urls: list, append: bool) + signal subtitleDropped(url: url) + + function isSubtitleFile(url: url): bool { + return /\.(srt|ass|ssa|vtt|sub|sup)$/i.test(url.toString()) + } + + // Plain-text and translated subtitles; styled ASS subtitles keep their own fonts. + function applySubtitleStyle() { + setPropertyAsync("sub-font-size", QtPotatoSettings.subtitleFontSize) + setPropertyAsync("sub-outline-size", QtPotatoSettings.subtitleOutlineSize) + } + + loadUserConfig: QtPotatoSettings.loadMpvConfig + + // Set once the saved volume is applied; mpv reports its default volume before that. + property bool volumeRestored: false + + // QtPotato's own settings win over mpv.conf. + onUserConfigApplied: { + applySubtitleStyle() + volume = QtPotatoSettings.volume + volumeRestored = true + } + + onVolumeChanged: { + if (volumeRestored) { + QtPotatoSettings.volume = volume + } + } + + readonly property int subtitleFontSize: QtPotatoSettings.subtitleFontSize + onSubtitleFontSizeChanged: applySubtitleStyle() + readonly property int subtitleOutlineSize: QtPotatoSettings.subtitleOutlineSize + onSubtitleOutlineSizeChanged: applySubtitleStyle() + + Rectangle { + anchors.fill: parent + color: "black" + visible: !root.fileLoaded + z: -1 + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: !root.fileLoaded && !dropArea.containsDrag + 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: root.actions.openFile + } + + Timer { + id: hideCursorTimer + running: root.fullscreen && mouseArea.containsMouse + repeat: true + interval: 2000 + onTriggered: mouseArea.hideCursor = true + } + + MouseArea { + id: mouseArea + + property bool hideCursor: false + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton | Qt.ForwardButton | Qt.BackButton + hoverEnabled: true + cursorShape: hideCursor && root.fullscreen ? Qt.BlankCursor : Qt.ArrowCursor + + onPositionChanged: mouse => { + hideCursor = false + hideCursorTimer.restart() + root.pointerMoved(mouse.x, mouse.y) + } + onExited: root.pointerLeft() + + onPressed: mouse => { + switch (mouse.button) { + case Qt.LeftButton: + root.actions.playPause.trigger() + break + case Qt.MiddleButton: + root.actions.mute.trigger() + break + case Qt.RightButton: + root.actions.openContextMenu.trigger() + break + case Qt.ForwardButton: + root.actions.seekForwardSmall.trigger() + break + case Qt.BackButton: + root.actions.seekBackwardSmall.trigger() + break + } + } + + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + // Both presses of the double click toggled playback, so it is unchanged. + root.actions.toggleFullscreen.trigger() + } else if (mouse.button === Qt.MiddleButton) { + root.actions.configure.trigger() + } + } + + onWheel: wheel => { + if (wheel.angleDelta.y > 0) { + root.actions.volumeUp.trigger() + } else if (wheel.angleDelta.y < 0) { + root.actions.volumeDown.trigger() + } + } + } + + DropArea { + id: dropArea + + readonly property bool onTopPart: containsDrag && drag.y <= height * 0.25 + property bool onlySubtitles: false + + anchors.fill: parent + keys: ["text/uri-list"] + + onEntered: drag => { + onlySubtitles = drag.urls.length > 0 && drag.urls.every(url => root.isSubtitleFile(url)) + } + + onDropped: drop => { + if (!drop.hasUrls) { + return + } + if (onlySubtitles) { + root.subtitleDropped(drop.urls[0]) + } else { + root.filesDropped(drop.urls, onTopPart) + } + } + + Repeater { + model: dropArea.containsDrag && !dropArea.onlySubtitles ? 2 : 0 + delegate: Rectangle { + id: dropZone + + required property int index + readonly property bool isTop: index === 0 + readonly property bool active: isTop === dropArea.onTopPart + + x: Kirigami.Units.smallSpacing + y: isTop ? Kirigami.Units.smallSpacing : dropArea.height * 0.25 + Kirigami.Units.smallSpacing + width: dropArea.width - Kirigami.Units.smallSpacing * 2 + height: (isTop ? dropArea.height * 0.25 : dropArea.height * 0.75) - Kirigami.Units.smallSpacing * 2 + color: "transparent" + radius: Kirigami.Units.cornerRadius + border.color: active ? Kirigami.Theme.activeTextColor : "transparent" + border.width: active ? 3 : 0 + + Label { + anchors.fill: parent + text: dropZone.isTop + ? KI18n.i18nc("@info:drag&drop append items", "Add to Playlist") + : KI18n.i18nc("@info:drag&drop play now", "Play Now") + elide: Text.ElideRight + color: "white" + style: Text.Outline + styleColor: "black" + opacity: dropZone.active ? 1 : 0.7 + font.pointSize: Kirigami.Theme.defaultFont.pointSize * (dropZone.isTop ? 1.6 : 2.2) + font.bold: true + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + } + } + } + } +} diff --git a/src/qml/Osd.qml b/src/qml/Osd.qml new file mode 100644 index 0000000..b6a831e --- /dev/null +++ b/src/qml/Osd.qml @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// 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 + +// Short status messages in the top left corner of the video. +Item { + id: root + + required property int maxWidth + + function message(text: string) { + if (!text) { + return + } + label.text = text + label.visible = true + timer.restart() + } + + Label { + id: label + + property int textWidth: Math.ceil(textMetrics.advanceWidth) + padding * 2 + + x: Kirigami.Units.largeSpacing + y: Kirigami.Units.largeSpacing + width: textWidth > root.maxWidth ? root.maxWidth : undefined + visible: false + background: Rectangle { + color: Qt.alpha(Kirigami.Theme.backgroundColor, 0.8) + radius: Kirigami.Units.cornerRadius + } + padding: Kirigami.Units.largeSpacing + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.2 + maximumLineCount: 5 + wrapMode: Text.WrapAnywhere + + TextMetrics { + id: textMetrics + font: label.font + text: label.text + } + } + + Timer { + id: timer + interval: 3000 + onTriggered: label.visible = false + } +} diff --git a/src/qml/PlaybackMenu.qml b/src/qml/PlaybackMenu.qml new file mode 100644 index 0000000..85ea2f4 --- /dev/null +++ b/src/qml/PlaybackMenu.qml @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&Playback") + + MenuItem { action: root.actions.playPause } + MenuItem { action: root.actions.stop } + MenuItem { action: root.actions.playNextFile } + MenuItem { action: root.actions.playPreviousFile } + MenuItem { action: root.actions.restart } + MenuSeparator {} + MenuItem { action: root.actions.speedUp } + MenuItem { action: root.actions.speedDown } + MenuItem { action: root.actions.speedReset } + MenuSeparator {} + Menu { + title: KI18n.i18nc("@title:menu", "Seek") + MenuItem { action: root.actions.seekForwardSmall } + MenuItem { action: root.actions.seekBackwardSmall } + MenuSeparator {} + MenuItem { action: root.actions.seekForwardMedium } + MenuItem { action: root.actions.seekBackwardMedium } + MenuSeparator {} + MenuItem { action: root.actions.seekForwardBig } + MenuItem { action: root.actions.seekBackwardBig } + MenuSeparator {} + MenuItem { action: root.actions.seekNextSubtitle } + MenuItem { action: root.actions.seekPreviousSubtitle } + MenuSeparator {} + MenuItem { action: root.actions.seekNextChapter } + MenuItem { action: root.actions.seekPreviousChapter } + MenuSeparator {} + MenuItem { action: root.actions.frameStepForward } + MenuItem { action: root.actions.frameStepBackward } + } +} diff --git a/src/qml/PlaybackSettings.qml b/src/qml/PlaybackSettings.qml new file mode 100644 index 0000000..a027842 --- /dev/null +++ b/src/qml/PlaybackSettings.qml @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n + +SettingsBasePage { + id: root + + GridLayout { + columns: 2 + + 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 { + from: 1 + to: 600 + value: QtPotatoSettings.seekSmallStep + textFromValue: (value, locale) => KI18n.i18ncp("@item:valuesuffix", "%1 second", "%1 seconds", value) + valueFromText: (text, locale) => parseInt(text) || 1 + onValueModified: { + QtPotatoSettings.seekSmallStep = value + QtPotatoSettings.save() + } + } + + Label { + text: KI18n.i18nc("@label:spinbox", "Medium step") + Layout.alignment: Qt.AlignRight + } + + SpinBox { + from: 1 + to: 600 + value: QtPotatoSettings.seekMediumStep + textFromValue: (value, locale) => KI18n.i18ncp("@item:valuesuffix", "%1 second", "%1 seconds", value) + valueFromText: (text, locale) => parseInt(text) || 1 + onValueModified: { + QtPotatoSettings.seekMediumStep = value + QtPotatoSettings.save() + } + } + + Label { + text: KI18n.i18nc("@label:spinbox", "Big step") + Layout.alignment: Qt.AlignRight + } + + SpinBox { + from: 1 + to: 600 + value: QtPotatoSettings.seekBigStep + textFromValue: (value, locale) => KI18n.i18ncp("@item:valuesuffix", "%1 second", "%1 seconds", value) + valueFromText: (text, locale) => parseInt(text) || 1 + onValueModified: { + QtPotatoSettings.seekBigStep = value + QtPotatoSettings.save() + } + } + + 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 { + from: 1 + to: 100 + value: QtPotatoSettings.volumeStep + onValueModified: { + QtPotatoSettings.volumeStep = value + QtPotatoSettings.save() + } + } + } +} diff --git a/src/qml/PlayerPage.qml b/src/qml/PlayerPage.qml deleted file mode 100644 index 25d63fd..0000000 --- a/src/qml/PlayerPage.qml +++ /dev/null @@ -1,383 +0,0 @@ -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 - // Set by the window. In full screen the bars float over the video and hide when the - // pointer rests. - property bool fullScreen: false - readonly property alias mediaTitle: mpv.mediaTitle - readonly property alias translator: translator - readonly property MpvItem mpvItem: mpv - - signal aboutRequested() - signal settingsRequested() - signal fullScreenToggled() - - title: KI18n.i18nc("@title", "Player") - padding: 0 - globalToolBarStyle: fullScreen ? Kirigami.ApplicationHeaderStyle.None : Kirigami.ApplicationHeaderStyle.ToolBar - - // Kept true for a moment after the pointer moves. - property bool pointerActive: false - readonly property bool controlsVisible: !fullScreen || pointerActive || topBarHover.hovered || footerHover.hovered - || subtitlePopup.visible || seekBar.pressed || !mpv.fileLoaded || menuOpen - // Set while the full screen "More" menu is open, so the bars stay. - property bool menuOpen: false - - onFullScreenChanged: page.revealControls() - - function revealControls() { - pointerActive = true - idleTimer.restart() - } - - Timer { - id: idleTimer - interval: 2500 - onTriggered: page.pointerActive = false - } - - actions: [ - Kirigami.Action { - icon.name: "document-open" - text: KI18n.i18nc("@action", "Open File…") - shortcut: StandardKey.Open - onTriggered: fileDialog.open() - }, - Kirigami.Action { - // F and Esc are handled by the Shortcuts below, which work while the bars are hidden. - icon.name: page.fullScreen ? "view-restore" : "view-fullscreen" - text: page.fullScreen ? KI18n.i18nc("@action", "Exit Full Screen") : KI18n.i18nc("@action", "Full Screen") - displayHint: Kirigami.DisplayHint.IconOnly - onTriggered: page.fullScreenToggled() - }, - Kirigami.Action { - icon.name: "configure" - text: KI18n.i18nc("@action", "Settings") - displayHint: Kirigami.DisplayHint.AlwaysHide - shortcut: StandardKey.Preferences - onTriggered: page.settingsRequested() - }, - Kirigami.Action { - icon.name: "help-about" - text: KI18n.i18nc("@action", "About QtPotato") - displayHint: Kirigami.DisplayHint.AlwaysHide - onTriggered: page.aboutRequested() - } - ] - - Component.onCompleted: { - page.applySubtitleStyle() - if (page.initialUrl.toString().length > 0) { - mpv.loadFile(page.initialUrl) - } - } - - // Plain-text and translated subtitles; styled ASS subtitles keep their own fonts. - function applySubtitleStyle() { - mpv.setPropertyAsync("sub-font-size", QtPotatoSettings.subtitleFontSize) - mpv.setPropertyAsync("sub-outline-size", QtPotatoSettings.subtitleOutlineSize) - } - - readonly property int subtitleFontSize: QtPotatoSettings.subtitleFontSize - onSubtitleFontSizeChanged: applySubtitleStyle() - readonly property int subtitleOutlineSize: QtPotatoSettings.subtitleOutlineSize - onSubtitleOutlineSizeChanged: applySubtitleStyle() - - 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: "F" - onActivated: page.fullScreenToggled() - } - Shortcut { - sequence: "Escape" - enabled: page.fullScreen - onActivated: page.fullScreenToggled() - } - Shortcut { - sequence: "Left" - onActivated: mpv.seek(mpv.position - 5) - } - Shortcut { - sequence: "Right" - onActivated: mpv.seek(mpv.position + 5) - } - // Same keys as mpv: z shows subtitles earlier, x later. - Shortcut { - sequence: "Z" - enabled: mpv.fileLoaded - onActivated: page.nudgeSubtitleDelay(-0.1) - } - Shortcut { - sequence: "X" - enabled: mpv.fileLoaded - onActivated: page.nudgeSubtitleDelay(0.1) - } - - function nudgeSubtitleDelay(seconds: real) { - const delay = mpv.subtitleDelay + seconds - mpv.subtitleDelay = delay - mpv.showText(KI18n.i18nc("@info:osd", "Subtitle delay: %1 ms", Math.round(delay * 1000))) - } - - TranslationService { - id: translator - onTranslated: (subtitleUrl, fromCache) => { - mpv.showTranslatedSubtitleFile(subtitleUrl, - KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)) - mpv.showText(fromCache - ? KI18n.i18nc("@info:osd", "Loaded the saved translation") - : KI18n.i18nc("@info:osd", "Translation ready")) - } - } - - Connections { - target: mpv - // A translation belongs to the file it was started for. - function onCurrentUrlChanged() { - translator.cancel() - } - } - - function isSubtitleFile(url: url): bool { - return /\.(srt|ass|ssa|vtt|sub|sup)$/i.test(url.toString()) - } - - 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) - } - - Item { - anchors.fill: parent - - MpvItem { - id: mpv - loadUserConfig: QtPotatoSettings.loadMpvConfig - // QtPotato's own subtitle settings win over mpv.conf. - onUserConfigApplied: page.applySubtitleStyle() - anchors { - left: parent.left - right: parent.right - top: parent.top - bottom: page.fullScreen ? parent.bottom : footer.top - } - - 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 - hoverEnabled: true - cursorShape: page.controlsVisible ? Qt.ArrowCursor : Qt.BlankCursor - onPositionChanged: page.revealControls() - onClicked: mpv.togglePause() - onDoubleClicked: page.fullScreenToggled() - } - - DropArea { - anchors.fill: parent - onDropped: drop => { - if (!drop.hasUrls) { - return - } - const url = drop.urls[0] - if (mpv.fileLoaded && page.isSubtitleFile(url)) { - mpv.addSubtitleFile(url) - } else { - mpv.loadFile(url) - } - } - } - } - - // Stands in for the page header in full screen. - QQC2.ToolBar { - id: topBar - anchors { - left: parent.left - right: parent.right - top: parent.top - } - position: QQC2.ToolBar.Header - // No fade: over a paused video Qt Quick renders frames too rarely for a smooth one. - visible: page.fullScreen && page.controlsVisible - - HoverHandler { - id: topBarHover - } - - contentItem: RowLayout { - spacing: Kirigami.Units.smallSpacing - - Kirigami.Heading { - Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.smallSpacing - level: 1 - elide: Text.ElideRight - text: mpv.mediaTitle - } - - // Plain buttons: Kirigami.ActionToolBar dropped the full screen action here. - QQC2.ToolButton { - action: page.actions[0] - focusPolicy: Qt.NoFocus - } - - QQC2.ToolButton { - action: page.actions[1] - focusPolicy: Qt.NoFocus - display: QQC2.AbstractButton.IconOnly - - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - - QQC2.ToolButton { - id: moreButton - focusPolicy: Qt.NoFocus - icon.name: "overflow-menu" - text: KI18n.i18nc("@action:button", "More") - display: QQC2.AbstractButton.IconOnly - down: moreMenu.visible - onPressed: moreMenu.popup(moreButton, 0, moreButton.height) - - QQC2.Menu { - id: moreMenu - onVisibleChanged: page.menuOpen = visible - QQC2.MenuItem { - action: page.actions[2] - } - QQC2.MenuItem { - action: page.actions[3] - } - } - } - } - } - - QQC2.ToolBar { - id: footer - anchors { - left: parent.left - right: parent.right - bottom: parent.bottom - } - position: QQC2.ToolBar.Footer - visible: page.controlsVisible - - HoverHandler { - id: footerHover - } - - 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 } - } - - QQC2.ToolButton { - id: subtitleButton - enabled: mpv.fileLoaded - focusPolicy: Qt.NoFocus - checkable: true - checked: subtitlePopup.visible - icon.name: "media-view-subtitles-symbolic" - text: KI18n.i18nc("@action:button", "Subtitles") - display: QQC2.AbstractButton.IconOnly - onClicked: subtitlePopup.visible ? subtitlePopup.close() : subtitlePopup.open() - - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered && !subtitlePopup.visible - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - - SubtitlePopup { - id: subtitlePopup - objectName: "subtitlePopup" - mpv: mpv - translator: translator - onSettingsRequested: page.settingsRequested() - x: subtitleButton.width - width - y: -height - Kirigami.Units.smallSpacing - } - } - } - } - } -} diff --git a/src/qml/Playlist.qml b/src/qml/Playlist.qml new file mode 100644 index 0000000..d4a9227 --- /dev/null +++ b/src/qml/Playlist.qml @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// Resizable panel at the left or right window edge listing the playlist. +Page { + id: root + + required property PlaylistModel playlist + required property MpvItem mpv + required property int mainWindowWidth + property bool fullscreen: false + + readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge + readonly property int buttonSize: Kirigami.Units.iconSizes.small + property real customWidth: QtPotatoSettings.playlistWidth + + signal addFilesRequested() + + function limitWidth(width: real): real { + return Math.min(Math.max(width, 260), mainWindowWidth - 50) + } + + function toggle() { + state = state === "visible" ? "hidden" : "visible" + } + + width: limitWidth(customWidth * (fullscreen ? 1.36 : 1)) + padding: 0 + state: "hidden" + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + background: Rectangle { + color: Qt.alpha(Kirigami.Theme.backgroundColor, QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1) + } + + header: ToolBar { + Kirigami.Theme.colorSet: Kirigami.Theme.Header + + RowLayout { + anchors.fill: parent + + Kirigami.Heading { + level: 4 + text: KI18n.i18ncp("@title playlist header", "Playlist (%1 file)", "Playlist (%1 files)", root.playlist.count) + elide: Text.ElideRight + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + } + + ToolButton { + text: KI18n.i18nc("@action:button", "Add…") + icon.name: "list-add" + icon.width: root.buttonSize + icon.height: root.buttonSize + focusPolicy: Qt.NoFocus + onClicked: root.addFilesRequested() + } + + ToolButton { + text: KI18n.i18nc("@action:button", "Scroll to Playing Item") + icon.name: "go-jump" + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + enabled: root.playlist.playingIndex >= 0 + onClicked: playlistView.positionViewAtIndex(root.playlist.playingIndex, ListView.Center) + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + + ToolButton { + text: KI18n.i18nc("@action:button", "Clear Playlist") + icon.name: "edit-clear-all" + icon.width: root.buttonSize + icon.height: root.buttonSize + display: AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + enabled: root.playlist.count > 0 + onClicked: root.playlist.clear() + + ToolTip.text: text + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + } + + ScrollView { + anchors.fill: parent + + ListView { + id: playlistView + + model: root.playlist + spacing: 1 + clip: true + reuseItems: true + currentIndex: root.playlist.playingIndex + highlightFollowsCurrentItem: false + + delegate: PlaylistItem { + playlist: root.playlist + mpv: root.mpv + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 2 + visible: root.playlist.count === 0 + icon.name: "view-media-playlist" + text: KI18n.i18nc("@info:placeholder", "Playlist is empty") + explanation: KI18n.i18nc("@info:placeholder", "Drop files on the top of the video to add them") + } + } + } + + // Border with a handle to resize the panel. + Rectangle { + id: edgeBorder + + x: root.edge === Qt.RightEdge ? 0 : root.width - width + y: -root.implicitHeaderHeight + width: 1 + height: root.height + color: Kirigami.Theme.backgroundColor + + ResizeHandler { + id: resizeHandlerTall + anchors.horizontalCenter: parent.horizontalCenter + width: 8 + height: parent.height + onMoved: delta => root.customWidth = root.limitWidth(root.customWidth + (root.edge === Qt.RightEdge ? -delta : delta)) + onReleased: { + QtPotatoSettings.playlistWidth = root.customWidth + QtPotatoSettings.save() + } + } + + Rectangle { + anchors.centerIn: parent + width: 5 + height: 50 + color: resizeHandlerTall.hovered ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor + radius: Kirigami.Units.cornerRadius + border.width: 1 + border.color: Kirigami.Theme.backgroundColor + } + } + + states: [ + State { + name: "hidden" + PropertyChanges { + root.x: root.edge === Qt.RightEdge ? root.mainWindowWidth : -root.width + root.visible: false + } + }, + State { + name: "visible" + PropertyChanges { + root.x: root.edge === Qt.RightEdge ? root.mainWindowWidth - root.width + 1 : 0 + root.visible: true + } + } + ] + + transitions: [ + Transition { + from: "visible" + to: "hidden" + SequentialAnimation { + NumberAnimation { + target: root + property: "x" + duration: Kirigami.Units.longDuration + easing.type: Easing.InQuad + } + PropertyAction { + target: root + property: "visible" + value: false + } + } + }, + Transition { + from: "hidden" + to: "visible" + SequentialAnimation { + PropertyAction { + target: root + property: "visible" + value: true + } + NumberAnimation { + target: root + property: "x" + duration: Kirigami.Units.longDuration + easing.type: Easing.OutQuad + } + } + } + ] +} diff --git a/src/qml/PlaylistItem.qml b/src/qml/PlaylistItem.qml new file mode 100644 index 0000000..419a4d6 --- /dev/null +++ b/src/qml/PlaylistItem.qml @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +// One row of the playlist: row number, playing icon and file name. +Item { + id: root + + required property int index + required property url url + required property string name + required property bool isPlaying + required property PlaylistModel playlist + required property MpvItem mpv + property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1 + + readonly property bool hovered: hoverHandler.hovered + readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize + + function labelColor(): color { + if (root.isPlaying && !root.hovered) { + return Kirigami.Theme.highlightedTextColor + } + return Kirigami.Theme.textColor + } + + implicitWidth: ListView.view.width + implicitHeight: fontSize * 3 + + HoverHandler { + id: hoverHandler + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onDoubleTapped: root.playlist.play(root.index) + } + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: contextMenu.popup() + } + + Rectangle { + anchors.fill: parent + Kirigami.Theme.colorSet: Kirigami.Theme.View + color: { + if (root.isPlaying && !root.hovered) { + return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha) + } + if (root.hovered) { + return Qt.alpha(Kirigami.Theme.hoverColor, root.alpha) + } + return Qt.alpha(Kirigami.Theme.backgroundColor, root.alpha) + } + + RowLayout { + anchors.fill: parent + spacing: Kirigami.Units.largeSpacing + + Label { + text: root.index + 1 + color: root.labelColor() + visible: QtPotatoSettings.playlistShowRowNumber + font.pointSize: root.fontSize + font.features: { "tnum": 1 } + horizontalAlignment: Qt.AlignHCenter + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.preferredWidth: rowNumberMetrics.width + } + + TextMetrics { + id: rowNumberMetrics + font.pointSize: root.fontSize + text: String(root.playlist.count).replace(/./g, "0") + } + + Rectangle { + color: Qt.alpha(Kirigami.Theme.textColor, 0.2) + visible: QtPotatoSettings.playlistShowRowNumber + Layout.preferredWidth: 1 + Layout.fillHeight: true + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + } + + Kirigami.Icon { + source: "media-playback-start" + 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 + } + + LabelWithTooltip { + text: root.name + toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url + color: root.labelColor() + elide: Text.ElideRight + font.pointSize: root.fontSize + verticalAlignment: Qt.AlignVCenter + Layout.fillWidth: true + Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0 + Layout.rightMargin: Kirigami.Units.largeSpacing + } + } + } + + Menu { + id: contextMenu + + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Play") + icon.name: "media-playback-start" + onTriggered: root.playlist.play(root.index) + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Open Containing Folder") + icon.name: "folder-open" + enabled: root.url.toString().startsWith("file://") + onTriggered: { + const path = root.url.toString() + Qt.openUrlExternally(path.substring(0, path.lastIndexOf("/"))) + } + } + MenuSeparator {} + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Move Up") + icon.name: "go-up" + enabled: root.index > 0 + onTriggered: root.playlist.move(root.index, root.index - 1) + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Move Down") + icon.name: "go-down" + enabled: root.index < root.playlist.count - 1 + onTriggered: root.playlist.move(root.index, root.index + 1) + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Remove from Playlist") + icon.name: "list-remove" + onTriggered: root.playlist.remove(root.index) + } + } +} diff --git a/src/qml/PlaylistSettings.qml b/src/qml/PlaylistSettings.qml new file mode 100644 index 0000000..f6d24f8 --- /dev/null +++ b/src/qml/PlaylistSettings.qml @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n + +SettingsBasePage { + id: root + + GridLayout { + columns: 2 + + Label { + text: KI18n.i18nc("@label:listbox", "Position") + Layout.alignment: Qt.AlignRight + } + + ComboBox { + textRole: "text" + valueRole: "value" + model: [ + { text: KI18n.i18nc("@item:inlistbox playlist position", "Left"), value: "left" }, + { text: KI18n.i18nc("@item:inlistbox playlist position", "Right"), value: "right" } + ] + Component.onCompleted: currentIndex = indexOfValue(QtPotatoSettings.playlistPosition) + onActivated: { + QtPotatoSettings.playlistPosition = currentValue + QtPotatoSettings.save() + } + } + + Label { + text: KI18n.i18nc("@label:listbox", "When a file ends") + Layout.alignment: Qt.AlignRight + } + + ComboBox { + textRole: "text" + valueRole: "value" + model: [ + { text: KI18n.i18nc("@item:inlistbox playback behavior", "Play the next file, stop after the last"), value: "StopAfterLast" }, + { text: KI18n.i18nc("@item:inlistbox playback behavior", "Play the next file, repeat the playlist"), value: "RepeatPlaylist" }, + { text: KI18n.i18nc("@item:inlistbox playback behavior", "Stop"), value: "StopAfterItem" }, + { text: KI18n.i18nc("@item:inlistbox playback behavior", "Repeat the file"), value: "RepeatItem" } + ] + Component.onCompleted: currentIndex = indexOfValue(QtPotatoSettings.playbackBehavior) + onActivated: { + QtPotatoSettings.playbackBehavior = currentValue + QtPotatoSettings.save() + } + } + + Item { Layout.preferredWidth: 1 } + + RowLayout { + CheckBox { + text: KI18n.i18nc("@option:check", "Add the other files of the folder") + checked: QtPotatoSettings.playlistLoadSiblings + onClicked: { + QtPotatoSettings.playlistLoadSiblings = checked + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "Opening a file fills the playlist with the videos and audio files of its folder, sorted by name, so the next episode plays when one ends.") + } + } + + Item { Layout.preferredWidth: 1 } + + CheckBox { + text: KI18n.i18nc("@option:check", "Overlay video") + checked: QtPotatoSettings.playlistOverlayVideo + onClicked: { + QtPotatoSettings.playlistOverlayVideo = checked + QtPotatoSettings.save() + } + } + + Item { Layout.preferredWidth: 1 } + + RowLayout { + CheckBox { + text: KI18n.i18nc("@option:check", "Open with the mouse") + checked: QtPotatoSettings.playlistToggleWithMouse + onClicked: { + QtPotatoSettings.playlistToggleWithMouse = checked + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "The playlist opens when the mouse reaches its edge of the video, and closes when it leaves. When off, use the Playlist button in the footer or press P.") + } + } + + Item { Layout.preferredWidth: 1 } + + CheckBox { + text: KI18n.i18nc("@option:check", "Show row numbers") + checked: QtPotatoSettings.playlistShowRowNumber + onClicked: { + QtPotatoSettings.playlistShowRowNumber = checked + QtPotatoSettings.save() + } + } + } +} diff --git a/src/qml/RecentFilesMenu.qml b/src/qml/RecentFilesMenu.qml new file mode 100644 index 0000000..ae5cc1b --- /dev/null +++ b/src/qml/RecentFilesMenu.qml @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "Recent Files") + enabled: RecentFilesModel.count > 0 + + Instantiator { + model: RecentFilesModel + delegate: MenuItem { + required property url url + required property string name + required property string path + text: name + onTriggered: root.actions.fileOpenRequested(url) + + ToolTip.text: path + ToolTip.visible: hovered + ToolTip.delay: 1000 + } + onObjectAdded: (index, object) => root.insertItem(index, object) + onObjectRemoved: (index, object) => root.removeItem(object) + } + + MenuSeparator {} + + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Clear List") + icon.name: "edit-clear-history" + onTriggered: RecentFilesModel.clear() + } +} diff --git a/src/qml/ResizeHandler.qml b/src/qml/ResizeHandler.qml new file mode 100644 index 0000000..22b22aa --- /dev/null +++ b/src/qml/ResizeHandler.qml @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick + +// Drag area that reports horizontal movement, for resizing side panels. +Item { + id: root + + property alias hovered: mouseArea.containsMouse + + signal moved(delta: real) + signal released() + + MouseArea { + id: mouseArea + + property real pressX: 0 + + anchors.fill: parent + acceptedButtons: Qt.LeftButton + cursorShape: Qt.SizeHorCursor + hoverEnabled: true + preventStealing: true + + onPressed: mouse => pressX = mouse.x + onPositionChanged: mouse => { + if (pressed) { + root.moved(mouse.x - pressX) + } + } + onReleased: root.released() + } +} diff --git a/src/qml/SeekBar.qml b/src/qml/SeekBar.qml new file mode 100644 index 0000000..559eed5 --- /dev/null +++ b/src/qml/SeekBar.qml @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +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, +// chapter marks, right click for the chapter list, wheel to seek. +Slider { + id: root + + required property MpvItem mpv + required property Actions actions + + readonly property alias chaptersPopupOpen: chaptersPopup.visible + readonly property bool showChapterMarkers: root.mpv.chapters.length * 20 < width + // Playing state before a drag started; playback pauses while dragging. + property bool pausedBeforeDrag: false + + function formatTime(totalSeconds: real): string { + const s = Math.max(0, Math.floor(totalSeconds)) + const h = Math.floor(s / 3600) + const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0") + const sec = String(s % 60).padStart(2, "0") + return `${String(h).padStart(2, "0")}:${m}:${sec}` + } + + function chapterTitleAt(time: real): string { + let title = "" + for (const chapter of root.mpv.chapters) { + if (chapter.time > time) { + break + } + title = chapter.title + } + return title + } + + function timeLabel(time: real): string { + const chapter = chapterTitleAt(time) + return chapter ? `${formatTime(time)} - ${chapter}` : formatTime(time) + } + + from: 0 + to: Math.max(root.mpv.duration, 0) + enabled: root.mpv.fileLoaded && root.mpv.duration > 0 + focusPolicy: Qt.NoFocus + leftPadding: 0 + rightPadding: 0 + + 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 + } + + 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 + } + } + + 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() + } + } + } + } + + onMoved: root.mpv.seek(value) + + onPressedChanged: { + if (pressed) { + pausedBeforeDrag = root.mpv.paused + root.mpv.paused = true + timeToolTip.pointerX = root.visualPosition * root.width + } else { + root.mpv.paused = pausedBeforeDrag + } + } + + // Follow playback, but not while the user drags the bar. + Binding on value { + value: root.mpv.position + when: !root.pressed + restoreMode: Binding.RestoreNone + } + + Connections { + target: root + enabled: root.pressed + function onValueChanged() { + timeToolTip.pointerX = root.visualPosition * root.width + } + } + + Popup { + id: chaptersPopup + + property int itemWidth: Kirigami.Units.gridUnit * 16 + + y: -height - root.height + width: Math.min(itemWidth, Math.max(Kirigami.Units.gridUnit * 40, root.width * 0.8)) + height: Math.min(Kirigami.Units.gridUnit * 20, listView.contentHeight) + modal: true + padding: 0 + + onOpened: listView.positionViewAtIndex(Math.max(0, root.mpv.chapter), ListView.Beginning) + + ScrollView { + anchors.fill: parent + + ListView { + id: listView + clip: true + model: root.mpv.chapters + delegate: RadioDelegate { + required property int index + required property var modelData + width: ListView.view.width + text: `${root.formatTime(modelData.time)} - ${modelData.title}` + checked: index === root.mpv.chapter + onClicked: { + chaptersPopup.close() + root.mpv.chapter = index + } + onImplicitWidthChanged: chaptersPopup.itemWidth = Math.max(chaptersPopup.itemWidth, implicitWidth + Kirigami.Units.gridUnit * 2) + } + } + } + } +} diff --git a/src/qml/SettingsBasePage.qml b/src/qml/SettingsBasePage.qml new file mode 100644 index 0000000..9ee70b6 --- /dev/null +++ b/src/qml/SettingsBasePage.qml @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import org.kde.kirigami as Kirigami + +// Page of the settings window. Changes are saved as they are made. +Kirigami.ScrollablePage { + globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None + Component.onDestruction: QtPotatoSettings.save() +} diff --git a/src/qml/SettingsHeader.qml b/src/qml/SettingsHeader.qml new file mode 100644 index 0000000..d7d06f4 --- /dev/null +++ b/src/qml/SettingsHeader.qml @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// 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 + + 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 + } + } +} diff --git a/src/qml/SettingsMenu.qml b/src/qml/SettingsMenu.qml new file mode 100644 index 0000000..732c49f --- /dev/null +++ b/src/qml/SettingsMenu.qml @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&Settings") + + MenuItem { + text: KI18n.i18nc("@action:inmenu", "General") + onTriggered: root.actions.settingsRequested("general") + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Playback") + onTriggered: root.actions.settingsRequested("playback") + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Subtitles") + onTriggered: root.actions.settingsRequested("subtitles") + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Translation") + onTriggered: root.actions.settingsRequested("translation") + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "Playlist") + onTriggered: root.actions.settingsRequested("playlist") + } + MenuItem { + text: KI18n.i18nc("@action:inmenu", "About") + onTriggered: root.actions.settingsRequested("about") + } +} diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml deleted file mode 100644 index d1e2b01..0000000 --- a/src/qml/SettingsPage.qml +++ /dev/null @@ -1,185 +0,0 @@ -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Layouts -import org.kde.kirigami as Kirigami -import org.kde.kirigamiaddons.formcard as FormCard -import org.kde.ki18n - -FormCard.FormCardPage { - id: page - - required property TranslationService translator - required property MpvItem mpvItem - - title: KI18n.i18nc("@title", "Settings") - - // Fields write to the settings object as they change; this writes it to disk. - Component.onDestruction: QtPotatoSettings.save() - - Kirigami.InlineMessage { - Layout.fillWidth: true - Layout.topMargin: Kirigami.Units.largeSpacing - Layout.leftMargin: Kirigami.Units.largeSpacing - Layout.rightMargin: Kirigami.Units.largeSpacing - type: Kirigami.MessageType.Error - text: page.translator.errorText || ApiKeyStore.error - visible: text.length > 0 - showCloseButton: true - onVisibleChanged: if (!visible) page.translator.clearError() - } - - FormCard.FormHeader { - title: KI18n.i18nc("@title:group", "Player") - } - - FormCard.FormCard { - FormCard.FormSwitchDelegate { - text: KI18n.i18nc("@option:check", "Use mpv.conf") - description: KI18n.i18nc("@info", "Reads mpv.conf from %1, like the mpv player does. Changes apply the next time QtPotato starts. Subtitle text size and outline set below take precedence.", page.mpvItem.userConfigDir) - checked: QtPotatoSettings.loadMpvConfig - onToggled: QtPotatoSettings.loadMpvConfig = checked - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormTextDelegate { - text: page.mpvItem.loadedUserConfigFiles.length > 0 - ? KI18n.i18nc("@info", "Loaded: %1", page.mpvItem.loadedUserConfigFiles.join(", ")) - : (QtPotatoSettings.loadMpvConfig - ? KI18n.i18nc("@info", "No mpv.conf found") - : KI18n.i18nc("@info", "mpv.conf is not used")) - description: page.mpvItem.skippedUserOptions.length > 0 - ? KI18n.i18nc("@info", "Ignored because they would break the built-in player: %1", page.mpvItem.skippedUserOptions.join(", ")) - : "" - } - } - - FormCard.FormHeader { - title: KI18n.i18nc("@title:group", "Subtitles") - } - - FormCard.FormCard { - FormCard.FormSpinBoxDelegate { - label: KI18n.i18nc("@label:spinbox", "Text size") - description: KI18n.i18nc("@info", "Text size and outline apply to translated and plain-text subtitles. Styled subtitles (ASS) keep the look they were made with.") - from: 20 - to: 150 - stepSize: 2 - value: QtPotatoSettings.subtitleFontSize - onValueModified: QtPotatoSettings.subtitleFontSize = value - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormSpinBoxDelegate { - label: KI18n.i18nc("@label:spinbox", "Outline size") - from: 0 - to: 10 - value: QtPotatoSettings.subtitleOutlineSize - onValueModified: QtPotatoSettings.subtitleOutlineSize = value - } - } - - FormCard.FormHeader { - title: KI18n.i18nc("@title:group", "Subtitle Translation") - } - - FormCard.FormCard { - FormCard.FormTextFieldDelegate { - label: KI18n.i18nc("@label:textbox", "API base URL") - placeholderText: "https://example.com/v1" - text: QtPotatoSettings.apiBaseUrl - inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase - onTextEdited: QtPotatoSettings.apiBaseUrl = text.trim() - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormPasswordFieldDelegate { - id: apiKeyField - label: KI18n.i18nc("@label:textbox", "API key") - placeholderText: ApiKeyStore.hasKey - ? KI18n.i18nc("@info:placeholder", "Saved in the wallet. Type a new key to replace it.") - : KI18n.i18nc("@info:placeholder", "Not set") - onEditingFinished: { - if (text.trim().length > 0) { - ApiKeyStore.setKey(text) - text = "" - } - } - } - - FormCard.FormButtonDelegate { - visible: ApiKeyStore.hasKey - icon.name: "edit-delete-remove" - text: KI18n.i18nc("@action:button", "Remove Saved API Key") - onClicked: ApiKeyStore.setKey("") - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormComboBoxDelegate { - id: modelCombo - - // Swapping the list makes the combo box jump to its first entry; that must - // not overwrite the model the user chose. - property bool updatingList: false - - function showModels() { - const chosen = QtPotatoSettings.model - updatingList = true - model = page.translator.availableModels - currentIndex = page.translator.availableModels.indexOf(chosen) - editText = chosen - updatingList = false - } - - text: KI18n.i18nc("@label:listbox", "Model") - editable: true - onEditTextChanged: if (!updatingList) QtPotatoSettings.model = editText.trim() - Component.onCompleted: showModels() - - Connections { - target: page.translator - function onAvailableModelsChanged() { - modelCombo.showModels() - } - } - } - - FormCard.FormButtonDelegate { - icon.name: "view-refresh" - text: page.translator.fetchingModels - ? KI18n.i18nc("@action:button", "Loading Models…") - : KI18n.i18nc("@action:button", "Load Models from the API") - description: page.translator.availableModels.length > 0 - ? KI18n.i18ncp("@info", "%1 model available", "%1 models available", page.translator.availableModels.length) - : "" - enabled: !page.translator.fetchingModels - onClicked: page.translator.fetchModels() - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormTextFieldDelegate { - label: KI18n.i18nc("@label:textbox", "Translate into") - text: QtPotatoSettings.targetLanguage - onTextEdited: QtPotatoSettings.targetLanguage = text.trim() - } - - FormCard.FormDelegateSeparator {} - - FormCard.FormSpinBoxDelegate { - label: KI18n.i18nc("@label:spinbox", "Lines per request") - from: 5 - to: 200 - stepSize: 5 - value: QtPotatoSettings.batchSize - onValueModified: QtPotatoSettings.batchSize = value - } - } - - FormCard.FormSectionText { - 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.") - } -} diff --git a/src/qml/SettingsWindow.qml b/src/qml/SettingsWindow.qml new file mode 100644 index 0000000..3e86d41 --- /dev/null +++ b/src/qml/SettingsWindow.qml @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n +import org.kde.kirigami as Kirigami +import org.kde.coreaddons as KCoreAddons + +// Separate settings window with the list of pages on the left, as in Haruna. Created +// when first opened and destroyed when closed. +Loader { + id: root + + required property MpvItem mpv + required property TranslationService translator + + property string activePage: "general" + + function openPage(page: string) { + root.activePage = page + if (!root.active) { + root.active = true + return + } + const window = root.item as Kirigami.ApplicationWindow + window.showPage(page) + window.raise() + window.requestActivate() + } + + active: false + + sourceComponent: Kirigami.ApplicationWindow { + id: settingsWindow + + readonly property list pages: [ + QtObject { + readonly property string name: "general" + readonly property string text: KI18n.i18nc("@action", "General") + readonly property string iconName: "configure" + readonly property Component component: Component { + GeneralSettings { mpv: root.mpv } + } + }, + QtObject { + readonly property string name: "playback" + readonly property string text: KI18n.i18nc("@action", "Playback") + readonly property string iconName: "media-playback-start" + readonly property Component component: Component { + PlaybackSettings {} + } + }, + QtObject { + readonly property string name: "subtitles" + readonly property string text: KI18n.i18nc("@action", "Subtitles") + readonly property string iconName: "add-subtitle" + readonly property Component component: Component { + SubtitlesSettings {} + } + }, + QtObject { + readonly property string name: "translation" + readonly property string text: KI18n.i18nc("@action", "Translation") + readonly property string iconName: "translate" + readonly property Component component: Component { + TranslationSettings { translator: root.translator } + } + }, + QtObject { + readonly property string name: "playlist" + readonly property string text: KI18n.i18nc("@action", "Playlist") + readonly property string iconName: "view-media-playlist" + readonly property Component component: Component { + PlaylistSettings {} + } + }, + QtObject { + readonly property string name: "about" + readonly property string text: KI18n.i18nc("@action", "About") + readonly property string iconName: "help-about" + readonly property Component component: Component { + Kirigami.AboutPage { + globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None + aboutData: KCoreAddons.AboutData + } + } + } + ] + + function showPage(name: string) { + let index = 0 + for (let i = 0; i < pages.length; ++i) { + if (pages[i].name === name) { + index = i + } + } + pageList.currentIndex = index + pageStack.pop(pageStack.items[0]) + // Created here, with a parent in the scene; pages that the page stack + // creates from a component start without one, which Qt warns about. + currentPage?.destroy() + currentPage = pages[index].component.createObject(pageStack) + pageStack.push(currentPage) + } + + property Item currentPage: null + + width: 800 + height: 600 + minimumWidth: 750 + minimumHeight: 400 + title: KI18n.i18nc("@title:window", "Settings") + visible: true + + pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.None + pageStack.initialPage: Kirigami.ScrollablePage { + globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None + padding: 0 + Kirigami.Theme.colorSet: Kirigami.Theme.View + + ListView { + id: pageList + model: settingsWindow.pages + delegate: ItemDelegate { + required property int index + required property QtObject modelData + width: ListView.view.width + text: modelData.text + icon.name: modelData.iconName + highlighted: index === ListView.view.currentIndex + onClicked: settingsWindow.showPage(modelData.name) + } + } + } + + Component.onCompleted: { + pageStack.columnView.columnWidth = Kirigami.Units.gridUnit * 12 + showPage(root.activePage) + } + + onClosing: { + QtPotatoSettings.save() + root.active = false + } + + Shortcut { + sequence: "Esc" + onActivated: settingsWindow.close() + } + } +} diff --git a/src/qml/SubtitlePopup.qml b/src/qml/SubtitlePopup.qml deleted file mode 100644 index e8eec1a..0000000 --- a/src/qml/SubtitlePopup.qml +++ /dev/null @@ -1,185 +0,0 @@ -import QtQuick -import QtQuick.Controls as QQC2 -import QtQuick.Dialogs -import QtQuick.Layouts -import org.kde.kirigami as Kirigami -import org.kde.ki18n - -QQC2.Popup { - id: popup - - required property MpvItem mpv - required property TranslationService translator - - signal settingsRequested() - - readonly property var currentTrack: mpv.subtitleTracks.find(track => track.id === mpv.subtitleId) ?? null - - // [{ id, text }] with "Off" first, shared by both track pickers. - readonly property var trackModel: [{ id: 0, text: KI18n.i18nc("@item:inlistbox no subtitle", "Off") }] - .concat(mpv.subtitleTracks.map(track => ({ id: track.id, text: trackLabel(track) }))) - - function trackLabel(track: var): string { - const name = track.title || (track.external ? track.externalFilename.split("/").pop() : "") - const lang = track.lang ? `[${track.lang}]` : "" - const description = [name, lang].filter(part => part.length > 0).join(" ") - return KI18n.i18nc("@item:inlistbox subtitle track number and description", "%1: %2", - track.id, description || KI18n.i18nc("@item:inlistbox", "Untitled")) - } - - function indexOfTrack(id: int): int { - return Math.max(0, trackModel.findIndex(entry => entry.id === id)) - } - - padding: Kirigami.Units.largeSpacing - - contentItem: Kirigami.FormLayout { - QQC2.ComboBox { - id: primaryCombo - Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Subtitle:") - Layout.preferredWidth: Kirigami.Units.gridUnit * 16 - model: popup.trackModel - textRole: "text" - valueRole: "id" - onActivated: popup.mpv.subtitleId = currentValue - - // Always reflect what mpv actually selected, also after the user picks an entry. - Binding on currentIndex { - value: popup.indexOfTrack(popup.mpv.subtitleId) - restoreMode: Binding.RestoreNone - } - } - - QQC2.ComboBox { - id: secondaryCombo - Kirigami.FormData.label: KI18n.i18nc("@label:listbox", "Secondary subtitle:") - Layout.preferredWidth: Kirigami.Units.gridUnit * 16 - model: popup.trackModel - textRole: "text" - valueRole: "id" - onActivated: popup.mpv.secondarySubtitleId = currentValue - - Binding on currentIndex { - value: popup.indexOfTrack(popup.mpv.secondarySubtitleId) - restoreMode: Binding.RestoreNone - } - } - - RowLayout { - Kirigami.FormData.label: KI18n.i18nc("@label:spinbox", "Delay:") - spacing: Kirigami.Units.smallSpacing - - QQC2.SpinBox { - id: delaySpin - from: -600000 - to: 600000 - stepSize: 100 - editable: true - textFromValue: (value, locale) => KI18n.i18nc("@item:valuesuffix milliseconds", "%1 ms", value) - valueFromText: (text, locale) => parseInt(text) || 0 - onValueModified: popup.mpv.subtitleDelay = value / 1000 - - Binding on value { - value: Math.round(popup.mpv.subtitleDelay * 1000) - restoreMode: Binding.RestoreNone - } - } - - QQC2.ToolButton { - enabled: popup.mpv.subtitleDelay !== 0 - icon.name: "edit-undo" - text: KI18n.i18nc("@action:button", "Reset delay") - display: QQC2.AbstractButton.IconOnly - onClicked: popup.mpv.subtitleDelay = 0 - - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - } - - QQC2.Button { - icon.name: "add-subtitle" - text: KI18n.i18nc("@action:button", "Load Subtitle File…") - onClicked: subtitleDialog.open() - } - - Kirigami.Separator { - Kirigami.FormData.isSection: true - Kirigami.FormData.label: KI18n.i18nc("@title:group", "Translation") - } - - QQC2.Button { - visible: !popup.translator.busy - enabled: popup.currentTrack !== null - icon.name: "translate" - text: KI18n.i18nc("@action:button", "Translate Subtitle into %1", QtPotatoSettings.targetLanguage) - onClicked: popup.translator.translate(popup.mpv.currentUrl, popup.currentTrack, popup.mpv.mediaTitle) - - QQC2.ToolTip.text: KI18n.i18nc("@info:tooltip", "Translates the subtitle chosen above and shows the result in its place") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - - RowLayout { - visible: popup.translator.busy - Layout.preferredWidth: Kirigami.Units.gridUnit * 16 - spacing: Kirigami.Units.smallSpacing - - QQC2.ProgressBar { - Layout.fillWidth: true - from: 0 - to: 1 - value: popup.translator.progress - indeterminate: popup.translator.progress === 0 - } - - QQC2.ToolButton { - icon.name: "dialog-cancel" - text: KI18n.i18nc("@action:button", "Cancel translation") - display: QQC2.AbstractButton.IconOnly - onClicked: popup.translator.cancel() - - QQC2.ToolTip.text: text - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - } - - QQC2.Label { - visible: popup.translator.busy - text: popup.translator.statusText - } - - Kirigami.InlineMessage { - Layout.preferredWidth: Kirigami.Units.gridUnit * 22 - type: Kirigami.MessageType.Error - text: popup.translator.errorText - visible: text.length > 0 - showCloseButton: true - onVisibleChanged: if (!visible) popup.translator.clearError() - actions: Kirigami.Action { - icon.name: "configure" - text: KI18n.i18nc("@action:button", "Settings") - onTriggered: { - popup.close() - popup.settingsRequested() - } - } - } - } - - FileDialog { - id: subtitleDialog - title: KI18n.i18nc("@title:window", "Load Subtitle") - currentFolder: { - const url = popup.mpv.currentUrl.toString() - return url.substring(0, url.lastIndexOf("/")) - } - nameFilters: [ - KI18n.i18nc("@item:inlistbox file filter", "Subtitle files (*.srt *.ass *.ssa *.vtt *.sub *.sup)"), - KI18n.i18nc("@item:inlistbox file filter", "All files (*)") - ] - onAccepted: popup.mpv.addSubtitleFile(selectedFile) - } -} diff --git a/src/qml/SubtitleTracksMenu.qml b/src/qml/SubtitleTracksMenu.qml new file mode 100644 index 0000000..9e2851a --- /dev/null +++ b/src/qml/SubtitleTracksMenu.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +// Checkable list of subtitle tracks, for the primary or the secondary subtitle. +Menu { + id: root + + required property MpvItem mpv + property bool isPrimary: true + + readonly property int selectedId: isPrimary ? mpv.subtitleId : mpv.secondarySubtitleId + readonly property int otherId: isPrimary ? mpv.secondarySubtitleId : mpv.subtitleId + + function trackLabel(track: var): string { + const name = track.title || (track.external ? track.externalFilename.split("/").pop() : "") + const lang = track.lang ? `[${track.lang}]` : "" + const description = [name, lang].filter(part => part.length > 0).join(" ") + return KI18n.i18nc("@item:inmenu subtitle track number and description", "%1: %2", + track.id, description || KI18n.i18nc("@item:inmenu", "Untitled")) + } + + function select(id: int) { + if (root.isPrimary) { + root.mpv.subtitleId = id + } else { + root.mpv.secondarySubtitleId = id + } + } + + enabled: mpv.subtitleTracks.length > 0 + + MenuItem { + text: KI18n.i18nc("@item:inmenu no subtitle", "None") + checkable: true + checked: root.selectedId === 0 + onTriggered: { + root.select(0) + // Clicking a checked item unchecks it, which drops the binding. + checked = Qt.binding(() => root.selectedId === 0) + } + } + + Instantiator { + model: root.mpv.subtitleTracks + delegate: MenuItem { + required property var modelData + text: root.trackLabel(modelData) + checkable: true + checked: modelData.id === root.selectedId + // mpv cannot show one track in both places. + enabled: modelData.id !== root.otherId + onTriggered: root.select(modelData.id) + } + 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 new file mode 100644 index 0000000..5f05236 --- /dev/null +++ b/src/qml/SubtitlesMenu.qml @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + required property MpvItem mpv + + title: KI18n.i18nc("@title:menu", "&Subtitles") + + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: true + title: KI18n.i18nc("@title:menu", "Primary Subtitle") + } + SubtitleTracksMenu { + mpv: root.mpv + isPrimary: false + title: KI18n.i18nc("@title:menu", "Secondary Subtitle") + } + MenuSeparator {} + MenuItem { action: root.actions.translateSubtitle } + MenuItem { + action: root.actions.cancelTranslation + visible: root.actions.cancelTranslation.enabled + height: visible ? implicitHeight : 0 + } + MenuSeparator {} + MenuItem { action: root.actions.addSubtitle } + MenuItem { action: root.actions.subtitleBigger } + MenuItem { action: root.actions.subtitleSmaller } + MenuItem { action: root.actions.subtitleEarlier } + MenuItem { action: root.actions.subtitleLater } + MenuItem { action: root.actions.subtitleDelayReset } + MenuItem { action: root.actions.subtitleToggle } + MenuItem { action: root.actions.subtitleCycleUp } + MenuItem { action: root.actions.subtitleCycleDown } +} diff --git a/src/qml/SubtitlesSettings.qml b/src/qml/SubtitlesSettings.qml new file mode 100644 index 0000000..f526c73 --- /dev/null +++ b/src/qml/SubtitlesSettings.qml @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n + +SettingsBasePage { + id: root + + GridLayout { + columns: 2 + + Label { + text: KI18n.i18nc("@label:spinbox", "Font size") + Layout.alignment: Qt.AlignRight + } + + RowLayout { + SpinBox { + from: 20 + to: 150 + stepSize: 2 + value: QtPotatoSettings.subtitleFontSize + onValueModified: { + QtPotatoSettings.subtitleFontSize = value + QtPotatoSettings.save() + } + } + + ToolTipButton { + toolTipText: KI18n.i18nc("@info:tooltip", "Font size and outline apply to translated and plain-text subtitles. Styled subtitles (ASS) keep the look they were made with. The font itself comes from sub-font in mpv.conf.") + } + } + + Label { + text: KI18n.i18nc("@label:spinbox", "Outline size") + Layout.alignment: Qt.AlignRight + } + + SpinBox { + from: 0 + to: 10 + value: QtPotatoSettings.subtitleOutlineSize + onValueModified: { + QtPotatoSettings.subtitleOutlineSize = value + QtPotatoSettings.save() + } + } + } +} diff --git a/src/qml/ToolTipButton.qml b/src/qml/ToolTipButton.qml new file mode 100644 index 0000000..28c1640 --- /dev/null +++ b/src/qml/ToolTipButton.qml @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.kirigami as Kirigami + +// Info button next to a setting; click it (or hover) to read what the setting does. +ToolButton { + id: root + + required property string toolTipText + + icon.name: "documentinfo" + focusPolicy: Qt.NoFocus + checkable: true + + ToolTip { + y: root.height + visible: root.checked || root.hovered + 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 + } +} diff --git a/src/qml/TranslationSettings.qml b/src/qml/TranslationSettings.qml new file mode 100644 index 0000000..e78c2e6 --- /dev/null +++ b/src/qml/TranslationSettings.qml @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.ki18n +import org.kde.kirigami as Kirigami + +SettingsBasePage { + id: root + + required property TranslationService translator + + ColumnLayout { + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: root.translator.errorText || ApiKeyStore.error + 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 + } + + TextField { + placeholderText: "https://example.com/v1" + text: QtPotatoSettings.apiBaseUrl + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase + Layout.fillWidth: true + onTextEdited: QtPotatoSettings.apiBaseUrl = text.trim() + onEditingFinished: QtPotatoSettings.save() + } + + Label { + text: KI18n.i18nc("@label:textbox", "API key") + Layout.alignment: Qt.AlignRight + } + + RowLayout { + Kirigami.PasswordField { + id: apiKeyField + 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 + onEditingFinished: { + if (text.trim().length > 0) { + ApiKeyStore.setKey(text) + text = "" + } + } + } + + Button { + visible: ApiKeyStore.hasKey + icon.name: "edit-delete-remove" + text: KI18n.i18nc("@action:button", "Remove") + onClicked: ApiKeyStore.setKey("") + + ToolTip.text: KI18n.i18nc("@info:tooltip", "Remove the saved API key from the wallet") + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + Label { + text: KI18n.i18nc("@label:listbox", "Model") + Layout.alignment: Qt.AlignRight + } + + RowLayout { + ComboBox { + id: modelCombo + + // Swapping the list makes the combo box jump to its first entry; that + // must not overwrite the model the user chose. + property bool updatingList: false + + function showModels() { + const chosen = QtPotatoSettings.model + updatingList = true + model = root.translator.availableModels + currentIndex = root.translator.availableModels.indexOf(chosen) + editText = chosen + updatingList = false + } + + editable: true + Layout.fillWidth: true + onEditTextChanged: if (!updatingList) QtPotatoSettings.model = editText.trim() + onActivated: QtPotatoSettings.save() + Component.onCompleted: showModels() + + Connections { + target: root.translator + function onAvailableModelsChanged() { + modelCombo.showModels() + } + } + } + + Button { + icon.name: "view-refresh" + text: root.translator.fetchingModels + ? KI18n.i18nc("@action:button", "Loading…") + : KI18n.i18nc("@action:button", "Load Models") + enabled: !root.translator.fetchingModels + onClicked: root.translator.fetchModels() + + ToolTip.text: KI18n.i18nc("@info:tooltip", "Ask the API which models it offers") + ToolTip.visible: hovered + ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + Item { + Layout.preferredWidth: 1 + visible: modelCountLabel.visible + } + + Label { + id: modelCountLabel + visible: root.translator.availableModels.length > 0 + text: KI18n.i18ncp("@info", "%1 model available", "%1 models available", root.translator.availableModels.length) + opacity: 0.7 + } + + Label { + text: KI18n.i18nc("@label:textbox", "Translate into") + Layout.alignment: Qt.AlignRight + } + + TextField { + text: QtPotatoSettings.targetLanguage + Layout.fillWidth: true + onTextEdited: QtPotatoSettings.targetLanguage = text.trim() + onEditingFinished: QtPotatoSettings.save() + } + + Label { + text: KI18n.i18nc("@label:spinbox", "Lines per request") + Layout.alignment: Qt.AlignRight + } + + SpinBox { + from: 5 + to: 200 + stepSize: 5 + value: QtPotatoSettings.batchSize + onValueModified: { + QtPotatoSettings.batchSize = value + 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/qml/VideoMenu.qml b/src/qml/VideoMenu.qml new file mode 100644 index 0000000..dbbf4ea --- /dev/null +++ b/src/qml/VideoMenu.qml @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&Video") + + MenuItem { action: root.actions.toggleDeinterlacing } + MenuItem { action: root.actions.zoomIn } + MenuItem { action: root.actions.zoomOut } + MenuItem { action: root.actions.zoomReset } + MenuItem { action: root.actions.screenshot } + MenuItem { action: root.actions.rotateClockwise } + MenuItem { action: root.actions.rotateCounterClockwise } +} diff --git a/src/qml/ViewMenu.qml b/src/qml/ViewMenu.qml new file mode 100644 index 0000000..2398ab1 --- /dev/null +++ b/src/qml/ViewMenu.qml @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import org.kde.ki18n + +Menu { + id: root + + required property Actions actions + + title: KI18n.i18nc("@title:menu", "&View") + + MenuItem { action: root.actions.toggleFullscreen } + MenuItem { action: root.actions.togglePlaylist } + MenuSeparator {} + MenuItem { action: root.actions.toggleMenuBar } + MenuItem { action: root.actions.toggleHeader } +} diff --git a/src/qml/VolumeSlider.qml b/src/qml/VolumeSlider.qml new file mode 100644 index 0000000..ca4e293 --- /dev/null +++ b/src/qml/VolumeSlider.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2020 George Florea Bănuș +// 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 +import org.kde.ki18n + +// Flat volume bar with the value written on it, as in Haruna. +Slider { + id: root + + required property MpvItem mpv + + from: 0 + to: root.mpv.volumeMax + implicitWidth: 100 + implicitHeight: 25 + wheelEnabled: true + stepSize: QtPotatoSettings.volumeStep + focusPolicy: Qt.NoFocus + leftPadding: 0 + rightPadding: 0 + + 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 + } + } + + onMoved: root.mpv.volume = Math.round(value) + + // Follow mpv, but not while the user drags (dragging replaces a plain binding). + 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" + } +} diff --git a/src/settings/qtpotatosettings.kcfg b/src/settings/qtpotatosettings.kcfg index e8f4729..0ee2dbb 100644 --- a/src/settings/qtpotatosettings.kcfg +++ b/src/settings/qtpotatosettings.kcfg @@ -3,11 +3,67 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd"> + + + + true + + + + true + + + + default + + + + 10 + 0 + 50 + + + + + + + + true + + + 100 + 0 + 1000 + + + + 5 + 1 + 100 + + + + 5 + 1 + 600 + + + + 15 + 1 + 600 + + + + 30 + 1 + 600 + @@ -23,6 +79,36 @@ 10 + + + + right + + + + 380 + + + + true + + + + true + + + + true + + + + true + + + + StopAfterLast + +