feat: translate subtitles with an OpenAI-compatible LLM
This commit is contained in:
1 parent
48cac86d60
commit
4665bf632a
21 files changed
+1877
-5
No files matched your search
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user