diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 71fcfb4..fe7b1b4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ ecm_add_qml_module(qtpotato ecm_target_qml_sources(qtpotato SOURCES qml/Main.qml qml/PlayerPage.qml + qml/SubtitlePopup.qml ) target_include_directories(qtpotato PRIVATE mpv) diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index 7999a37..c047190 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -2,19 +2,38 @@ #include +#include #include +namespace +{ +QString localPathOrUrl(const QUrl &url) +{ + return url.isLocalFile() ? url.toLocalFile() : url.toString(); +} + +// mpv takes "no" to deselect a track. +QVariant trackIdValue(int id) +{ + return id > 0 ? QVariant(id) : QVariant(QStringLiteral("no")); +} +} + MpvItem::MpvItem(QQuickItem *parent) : MpvAbstractItem(parent) { // Leave the player on the last frame at end of file instead of unloading it. setPropertyAsync(QStringLiteral("keep-open"), QStringLiteral("yes")); setPropertyAsync(QStringLiteral("hwdec"), QStringLiteral("auto-safe")); + // Also pick up "movie.en.srt" style files next to "movie.mkv". + setPropertyAsync(QStringLiteral("sub-auto"), QStringLiteral("fuzzy")); observeProperty(QStringLiteral("media-title"), MPV_FORMAT_STRING); observeProperty(QStringLiteral("time-pos"), MPV_FORMAT_DOUBLE); observeProperty(QStringLiteral("duration"), MPV_FORMAT_DOUBLE); observeProperty(QStringLiteral("pause"), MPV_FORMAT_FLAG); + observeProperty(QStringLiteral("track-list"), MPV_FORMAT_NODE); + observeProperty(QStringLiteral("sub-delay"), MPV_FORMAT_DOUBLE); // MpvController lives on a worker thread; queue its signals back to the GUI thread. connect(mpvController(), &MpvController::propertyChanged, this, &MpvItem::onPropertyChanged, Qt::QueuedConnection); @@ -98,9 +117,10 @@ void MpvItem::loadFile(const QUrl &url) return; } - const QString target = url.isLocalFile() ? url.toLocalFile() : url.toString(); - commandAsync(QStringList{QStringLiteral("loadfile"), target}); + commandAsync(QStringList{QStringLiteral("loadfile"), localPathOrUrl(url)}); setPropertyAsync(QStringLiteral("pause"), false); + // sub-delay is global in mpv; start every file in sync. + setSubtitleDelay(0.0); } void MpvItem::togglePause() @@ -113,6 +133,63 @@ void MpvItem::seek(double seconds) commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("absolute")}); } +QVariantList MpvItem::subtitleTracks() const +{ + return m_subtitleTracks; +} + +int MpvItem::subtitleId() const +{ + return m_subtitleId; +} + +void MpvItem::setSubtitleId(int id) +{ + setPropertyAsync(QStringLiteral("sid"), trackIdValue(id)); +} + +int MpvItem::secondarySubtitleId() const +{ + return m_secondarySubtitleId; +} + +void MpvItem::setSecondarySubtitleId(int id) +{ + setPropertyAsync(QStringLiteral("secondary-sid"), trackIdValue(id)); +} + +double MpvItem::subtitleDelay() const +{ + return m_subtitleDelay; +} + +void MpvItem::setSubtitleDelay(double seconds) +{ + // Keep whole milliseconds so repeated +/-0.1 steps don't drift. + const double rounded = std::round(seconds * 1000.0) / 1000.0; + // Update right away so quick repeated steps build on each other instead of on the + // last value mpv reported; mpv confirms it through the observed property. + if (rounded != m_subtitleDelay) { + m_subtitleDelay = rounded; + Q_EMIT subtitleDelayChanged(); + } + setPropertyAsync(QStringLiteral("sub-delay"), rounded); + setPropertyAsync(QStringLiteral("secondary-sub-delay"), rounded); +} + +void MpvItem::addSubtitleFile(const QUrl &url) +{ + if (!url.isValid()) { + return; + } + commandAsync(QStringList{QStringLiteral("sub-add"), localPathOrUrl(url), QStringLiteral("select")}); +} + +void MpvItem::showText(const QString &text) +{ + commandAsync(QStringList{QStringLiteral("show-text"), text}); +} + void MpvItem::onPropertyChanged(const QString &property, const QVariant &value) { if (property == u"media-title") { @@ -127,9 +204,52 @@ void MpvItem::onPropertyChanged(const QString &property, const QVariant &value) } else if (property == u"pause") { m_paused = value.toBool(); Q_EMIT pausedChanged(); + } else if (property == u"track-list") { + updateTracks(value.toList()); + } else if (property == u"sub-delay") { + m_subtitleDelay = value.toDouble(); + Q_EMIT subtitleDelayChanged(); } } +void MpvItem::updateTracks(const QVariantList &trackList) +{ + QVariantList subtitles; + int primary = 0; + int secondary = 0; + + for (const QVariant &entry : trackList) { + const QVariantMap track = entry.toMap(); + if (track.value(QStringLiteral("type")).toString() != u"sub") { + continue; + } + + const int id = track.value(QStringLiteral("id")).toInt(); + if (track.value(QStringLiteral("selected")).toBool()) { + // main-selection is 0 for the primary subtitle and 1 for the secondary one. + if (track.value(QStringLiteral("main-selection")).toInt() == 0) { + primary = id; + } else { + secondary = id; + } + } + + subtitles.append(QVariantMap{ + {QStringLiteral("id"), id}, + {QStringLiteral("title"), track.value(QStringLiteral("title")).toString()}, + {QStringLiteral("lang"), track.value(QStringLiteral("lang")).toString()}, + {QStringLiteral("codec"), track.value(QStringLiteral("codec")).toString()}, + {QStringLiteral("external"), track.value(QStringLiteral("external")).toBool()}, + {QStringLiteral("externalFilename"), track.value(QStringLiteral("external-filename")).toString()}, + }); + } + + m_subtitleTracks = subtitles; + m_subtitleId = primary; + m_secondarySubtitleId = secondary; + Q_EMIT subtitleTracksChanged(); +} + void MpvItem::setFileLoaded(bool loaded) { if (loaded == m_fileLoaded) { diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index b570f05..ba3286a 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -3,6 +3,7 @@ #include #include +#include #include // QML video surface backed by libmpv (through MpvQt). @@ -19,6 +20,14 @@ class MpvItem : public MpvAbstractItem Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged) Q_PROPERTY(bool fileLoaded READ isFileLoaded NOTIFY fileLoadedChanged) + // Subtitle tracks of the current file, each a map with id, title, lang, codec, + // external and externalFilename. Track id 0 means "no subtitle". + Q_PROPERTY(QVariantList subtitleTracks READ subtitleTracks NOTIFY subtitleTracksChanged) + Q_PROPERTY(int subtitleId READ subtitleId WRITE setSubtitleId NOTIFY subtitleTracksChanged) + Q_PROPERTY(int secondarySubtitleId READ secondarySubtitleId WRITE setSecondarySubtitleId NOTIFY subtitleTracksChanged) + // In seconds; applied to both subtitles so a translation stays in sync with its source. + Q_PROPERTY(double subtitleDelay READ subtitleDelay WRITE setSubtitleDelay NOTIFY subtitleDelayChanged) + public: explicit MpvItem(QQuickItem *parent = nullptr); ~MpvItem() override = default; @@ -31,9 +40,21 @@ public: void setPaused(bool paused); bool isFileLoaded() const; + QVariantList subtitleTracks() const; + int subtitleId() const; + void setSubtitleId(int id); + int secondarySubtitleId() const; + void setSecondarySubtitleId(int id); + double subtitleDelay() const; + void setSubtitleDelay(double seconds); + Q_INVOKABLE void loadFile(const QUrl &url); Q_INVOKABLE void togglePause(); Q_INVOKABLE void seek(double seconds); + // Adds an external subtitle file and makes it the primary subtitle. + Q_INVOKABLE void addSubtitleFile(const QUrl &url); + // Shows a short message on mpv's on-screen display. + Q_INVOKABLE void showText(const QString &text); Q_SIGNALS: void currentUrlChanged(); @@ -42,10 +63,13 @@ Q_SIGNALS: void durationChanged(); void pausedChanged(); void fileLoadedChanged(); + void subtitleTracksChanged(); + void subtitleDelayChanged(); private: void onPropertyChanged(const QString &property, const QVariant &value); void setFileLoaded(bool loaded); + void updateTracks(const QVariantList &trackList); bool m_ready = false; QUrl m_pendingUrl; @@ -55,4 +79,8 @@ private: double m_duration = 0.0; bool m_paused = false; bool m_fileLoaded = false; + QVariantList m_subtitleTracks; + int m_subtitleId = 0; + int m_secondarySubtitleId = 0; + double m_subtitleDelay = 0.0; }; diff --git a/src/qml/PlayerPage.qml b/src/qml/PlayerPage.qml index 3d600e6..f3be795 100644 --- a/src/qml/PlayerPage.qml +++ b/src/qml/PlayerPage.qml @@ -65,6 +65,27 @@ Kirigami.Page { 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))) + } + + function isSubtitleFile(url: url): bool { + return /\.(srt|ass|ssa|vtt|sub|sup)$/i.test(url.toString()) + } FileDialog { id: fileDialog @@ -111,8 +132,14 @@ Kirigami.Page { DropArea { anchors.fill: parent onDropped: drop => { - if (drop.hasUrls) { - mpv.loadFile(drop.urls[0]) + if (!drop.hasUrls) { + return + } + const url = drop.urls[0] + if (mpv.fileLoaded && page.isSubtitleFile(url)) { + mpv.addSubtitleFile(url) + } else { + mpv.loadFile(url) } } } @@ -165,6 +192,30 @@ Kirigami.Page { 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 + x: subtitleButton.width - width + y: -height - Kirigami.Units.smallSpacing + } + } } } } diff --git a/src/qml/SubtitlePopup.qml b/src/qml/SubtitlePopup.qml new file mode 100644 index 0000000..8d3b713 --- /dev/null +++ b/src/qml/SubtitlePopup.qml @@ -0,0 +1,116 @@ +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 + + // [{ 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() + } + } + + 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) + } +}