feat: rebuild the interface after Haruna

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
triandClaude Opus 5.5 committed 2026-10-28 09:26:47 +07:00
1 parent 54ad4eecf3
commit 614a31db1b
45 files changed
+3815 -795

No files matched your search

-4
View File
@@ -29,10 +29,6 @@ pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavut
qcoro_enable_coroutines() qcoro_enable_coroutines()
find_package(KF6KirigamiAddons 1.0)
set_package_properties(KF6KirigamiAddons PROPERTIES
TYPE RUNTIME
PURPOSE "FormCard settings and About pages")
add_subdirectory(src) add_subdirectory(src)
+35 -3
View File
@@ -57,10 +57,42 @@ ecm_add_qml_module(qtpotato
) )
ecm_target_qml_sources(qtpotato SOURCES 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/Main.qml
qml/PlayerPage.qml qml/MainMenuBar.qml
qml/SubtitlePopup.qml qml/MpvContextMenu.qml
qml/SettingsPage.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) target_include_directories(qtpotato PRIVATE models mpris mpv playlist translate)
+3
View File
@@ -42,6 +42,9 @@ int main(int argc, char *argv[])
i18n("© 2026 Tri Vo Khac")); i18n("© 2026 Tri Vo Khac"));
aboutData.addAuthor(i18n("Tri Vo Khac"), i18nc("@info:credit", "Author"), QString(), u"https://github.com/vokhactri"_s); 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.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"); aboutData.setOrganizationDomain("trivk.dev");
KAboutData::setApplicationData(aboutData); KAboutData::setApplicationData(aboutData);
QApplication::setWindowIcon(QIcon::fromTheme(u"multimedia-video-player"_s)); QApplication::setWindowIcon(QIcon::fromTheme(u"multimedia-video-player"_s));
-5
View File
@@ -327,11 +327,6 @@ void MpvItem::showOnlySubtitle(int id)
setSecondarySubtitleId(0); setSecondarySubtitleId(0);
} }
void MpvItem::showText(const QString &text)
{
commandAsync(QStringList{QStringLiteral("show-text"), text});
}
bool MpvItem::loadUserConfig() const bool MpvItem::loadUserConfig() const
{ {
return m_loadUserConfig; return m_loadUserConfig;
-2
View File
@@ -102,8 +102,6 @@ public:
// Makes \a url the only subtitle shown, adding it first (named \a title) unless it is // Makes \a url the only subtitle shown, adding it first (named \a title) unless it is
// already loaded. // already loaded.
Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title); 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: Q_SIGNALS:
void currentUrlChanged(); void currentUrlChanged();
+502
View File
@@ -0,0 +1,502 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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")
}
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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)
}
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+291
View File
@@ -0,0 +1,291 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
}
]
}
}
+143
View File
@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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.<br>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
}
}
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
}
}
+230
View File
@@ -0,0 +1,230 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
+299 -28
View File
@@ -1,44 +1,315 @@
import QtQuick // SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
import org.kde.kirigami as Kirigami // SPDX-FileCopyrightText: 2026 Tri Vo Khac
import org.kde.kirigamiaddons.formcard as FormCard // SPDX-License-Identifier: GPL-3.0-or-later
Kirigami.ApplicationWindow { import QtQuick
id: root 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. // Set from C++ (main.cpp) when a file is passed on the command line.
required property url initialUrl required property url initialUrl
width: Kirigami.Units.gridUnit * 50 property int previousVisibility: Window.Windowed
height: Kirigami.Units.gridUnit * 32 readonly property bool isFullScreen: visibility === Window.FullScreen
minimumWidth: Kirigami.Units.gridUnit * 20
minimumHeight: Kirigami.Units.gridUnit * 14
title: player.mediaTitle.length > 0 ? player.mediaTitle : "QtPotato" function openFile(url: url) {
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
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
} }
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar function toggleFullScreen() {
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn if (isFullScreen) {
exitFullScreen()
} else {
showFullScreen()
}
}
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() onClosing: QtPotatoSettings.save()
Component { Component.onCompleted: {
id: settingsPage if (initialUrl.toString().length > 0) {
SettingsPage { openFile(initialUrl)
translator: player.translator
mpvItem: player.mpvItem
} }
} }
Component { KConfig.WindowStateSaver {
id: aboutPage configGroupName: "MainWindow"
FormCard.AboutPage {} }
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)
} }
} }
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+196
View File
@@ -0,0 +1,196 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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<url>, 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
}
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
}
}
-383
View File
@@ -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
}
}
}
}
}
}
+217
View File
@@ -0,0 +1,217 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
}
]
}
+152
View File
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
+201
View File
@@ -0,0 +1,201 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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)
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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")
}
}
-185
View File
@@ -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.")
}
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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<QtObject> 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()
}
}
}
-185
View File
@@ -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)
}
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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)
}
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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()
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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
}
}
+175
View File
@@ -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
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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 }
}
+55
View File
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2020 George Florea Bănuș <georgefb899@gmail.com>
// 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"
}
}
+86
View File
@@ -3,11 +3,67 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 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"> xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name="qtpotatorc"/> <kcfgfile name="qtpotatorc"/>
<group name="General">
<entry name="ShowMenuBar" type="Bool">
<label>Show the menu bar above the toolbar</label>
<default>true</default>
</entry>
<entry name="ShowHeader" type="Bool">
<label>Show the toolbar above the video</label>
<default>true</default>
</entry>
<entry name="FooterStyle" type="String">
<label>"default" docks the playback controls below the video, "floating" shows them over it while the pointer moves</label>
<default>default</default>
</entry>
<entry name="MaxRecentFiles" type="Int">
<label>Files listed under Recent Files; 0 hides the list</label>
<default>10</default>
<min>0</min>
<max>50</max>
</entry>
<entry name="FileDialogLastLocation" type="Url">
<label>Folder the Open File dialog starts in</label>
</entry>
<entry name="LastUrl" type="String">
<label>Address last entered in Open URL</label>
</entry>
</group>
<group name="Player"> <group name="Player">
<entry name="LoadMpvConfig" type="Bool"> <entry name="LoadMpvConfig" type="Bool">
<label>Load the user's mpv.conf when the player starts</label> <label>Load the user's mpv.conf when the player starts</label>
<default>true</default> <default>true</default>
</entry> </entry>
<entry name="Volume" type="Int">
<label>Volume restored at startup</label>
<default>100</default>
<min>0</min>
<max>1000</max>
</entry>
<entry name="VolumeStep" type="Int">
<label>Volume change per step of the volume actions and the mouse wheel</label>
<default>5</default>
<min>1</min>
<max>100</max>
</entry>
<entry name="SeekSmallStep" type="Int">
<label>Seconds skipped by Left and Right</label>
<default>5</default>
<min>1</min>
<max>600</max>
</entry>
<entry name="SeekMediumStep" type="Int">
<label>Seconds skipped by Shift+Left and Shift+Right</label>
<default>15</default>
<min>1</min>
<max>600</max>
</entry>
<entry name="SeekBigStep" type="Int">
<label>Seconds skipped by Up and Down</label>
<default>30</default>
<min>1</min>
<max>600</max>
</entry>
</group> </group>
<group name="Subtitles"> <group name="Subtitles">
<entry name="SubtitleFontSize" type="Int"> <entry name="SubtitleFontSize" type="Int">
@@ -23,6 +79,36 @@
<max>10</max> <max>10</max>
</entry> </entry>
</group> </group>
<group name="Playlist">
<entry name="PlaylistPosition" type="String">
<label>Window edge the playlist opens at: "right" or "left"</label>
<default>right</default>
</entry>
<entry name="PlaylistWidth" type="Int">
<label>Width of the playlist panel</label>
<default>380</default>
</entry>
<entry name="PlaylistOverlayVideo" type="Bool">
<label>Show the playlist over the video instead of beside it</label>
<default>true</default>
</entry>
<entry name="PlaylistToggleWithMouse" type="Bool">
<label>Open the playlist when the pointer reaches its window edge</label>
<default>true</default>
</entry>
<entry name="PlaylistLoadSiblings" type="Bool">
<label>Opening a file also adds the other videos in its folder to the playlist</label>
<default>true</default>
</entry>
<entry name="PlaylistShowRowNumber" type="Bool">
<label>Show row numbers in the playlist</label>
<default>true</default>
</entry>
<entry name="PlaybackBehavior" type="String">
<label>What happens when a file ends: "StopAfterLast", "RepeatPlaylist", "StopAfterItem" or "RepeatItem"</label>
<default>StopAfterLast</default>
</entry>
</group>
<group name="Translation"> <group name="Translation">
<entry name="ApiBaseUrl" type="String"> <entry name="ApiBaseUrl" type="String">
<label>Base URL of the OpenAI-compatible API, including the version path (for example …/v1)</label> <label>Base URL of the OpenAI-compatible API, including the version path (for example …/v1)</label>