feat: translate subtitles with an OpenAI-compatible LLM

This commit is contained in:
tri committed 2026-10-27 22:43:03 +07:00
1 parent 48cac86d60
commit 4665bf632a
21 files changed
+1877 -5

No files matched your search

+10
View File
@@ -19,12 +19,22 @@ Kirigami.ApplicationWindow {
id: player
initialUrl: root.initialUrl
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
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
onClosing: QtPotatoSettings.save()
Component {
id: settingsPage
SettingsPage {
translator: player.translator
}
}
Component {
id: aboutPage
FormCard.AboutPage {}
+30
View File
@@ -10,8 +10,10 @@ Kirigami.Page {
property url initialUrl
readonly property alias mediaTitle: mpv.mediaTitle
readonly property alias translator: translator
signal aboutRequested()
signal settingsRequested()
signal fullScreenToggled()
title: KI18n.i18nc("@title", "Player")
@@ -31,6 +33,13 @@ Kirigami.Page {
shortcut: "F"
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")
@@ -83,6 +92,25 @@ Kirigami.Page {
mpv.showText(KI18n.i18nc("@info:osd", "Subtitle delay: %1 ms", Math.round(delay * 1000)))
}
TranslationService {
id: translator
onTranslated: (subtitleUrl, fromCache) => {
mpv.showSecondarySubtitleFile(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())
}
@@ -212,6 +240,8 @@ Kirigami.Page {
id: subtitlePopup
objectName: "subtitlePopup"
mpv: mpv
translator: translator
onSettingsRequested: page.settingsRequested()
x: subtitleButton.width - width
y: -height - Kirigami.Units.smallSpacing
}
+132
View File
@@ -0,0 +1,132 @@
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
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", "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.")
}
}
+69
View File
@@ -9,6 +9,11 @@ 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") }]
@@ -98,6 +103,70 @@ QQC2.Popup {
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 as the secondary subtitle")
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 {