#include "mpvitem.h" #include "mpvuserconfig.h" #include "trackselection.h" #include #include #include #include #include #include #include #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")); } QList selectionTracks(const QVariantList &tracks) { QList result; for (const QVariant &entry : tracks) { const QVariantMap track = entry.toMap(); result.append({ .id = track.value(QStringLiteral("id")).toInt(), .lang = track.value(QStringLiteral("lang")).toString(), .codec = track.value(QStringLiteral("codec")).toString(), .forced = track.value(QStringLiteral("forced")).toBool(), .isDefault = track.value(QStringLiteral("default")).toBool(), }); } return result; } QString subtitlePath(const QVariantList &tracks, int id) { for (const QVariant &entry : tracks) { const QVariantMap track = entry.toMap(); if (track.value(QStringLiteral("id")).toInt() == id) { return track.value(QStringLiteral("externalFilename")).toString(); } } return {}; } int subtitleIdForPath(const QVariantList &tracks, const QString &path) { if (path.isEmpty()) { return 0; } for (const QVariant &entry : tracks) { const QVariantMap track = entry.toMap(); if (track.value(QStringLiteral("externalFilename")).toString() == path) { return track.value(QStringLiteral("id")).toInt(); } } return 0; } // "movie.abc123.vi.srt" -> "vi", the way mpv tags subtitles it finds next to a video. QString filenameLanguage(const QString &path) { const QStringList parts = QFileInfo(path).completeBaseName().split(QLatin1Char('.')); return parts.size() > 1 ? parts.constLast() : QString(); } QString trackLanguage(const QVariantList &tracks, int id) { for (const QVariant &entry : tracks) { const QVariantMap track = entry.toMap(); if (track.value(QStringLiteral("id")).toInt() == id) { return track.value(QStringLiteral("lang")).toString(); } } return {}; } } 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")); // mpvqt renders with the default blocking timing, so each new video frame stalls // the Qt Quick render thread for up to this offset (50 ms) and animations drawn // over a playing video stutter. Hand frames over at their display time instead. setPropertyAsync(QStringLiteral("video-timing-offset"), 0); // 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); observeProperty(QStringLiteral("volume"), MPV_FORMAT_INT64); observeProperty(QStringLiteral("volume-max"), MPV_FORMAT_INT64); observeProperty(QStringLiteral("mute"), MPV_FORMAT_FLAG); observeProperty(QStringLiteral("sub-visibility"), MPV_FORMAT_FLAG); observeProperty(QStringLiteral("deinterlace"), MPV_FORMAT_FLAG); observeProperty(QStringLiteral("speed"), MPV_FORMAT_DOUBLE); observeProperty(QStringLiteral("chapter-list"), MPV_FORMAT_NODE); observeProperty(QStringLiteral("chapter"), MPV_FORMAT_INT64); observeProperty(QStringLiteral("eof-reached"), MPV_FORMAT_FLAG); // MpvController lives on a worker thread; queue its signals back to the GUI thread. connect(mpvController(), &MpvController::propertyChanged, this, &MpvItem::onPropertyChanged, Qt::QueuedConnection); connect( mpvController(), &MpvController::fileLoaded, this, [this] { setFileLoaded(true); selectInitialTracks(); }, Qt::QueuedConnection); connect( mpvController(), &MpvController::endFile, this, [this] { setFileLoaded(false); }, Qt::QueuedConnection); // The render context only exists once Qt Quick has rendered the item for the first // time; a file loaded before that plays without video. Hold it until ready() fires. connect(this, &MpvAbstractItem::ready, this, [this] { m_ready = true; // Before the first file, so its options apply from the start. applyUserConfig(); if (m_pendingUrl.isValid()) { loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle)); } }); } QUrl MpvItem::currentUrl() const { return m_currentUrl; } QString MpvItem::mediaTitle() const { return m_mediaTitle; } double MpvItem::position() const { return m_position; } double MpvItem::duration() const { return m_duration; } bool MpvItem::paused() const { return m_paused; } void MpvItem::setPaused(bool paused) { if (paused == m_paused) { return; } // m_paused is updated when mpv reports the "pause" property back. setPropertyAsync(QStringLiteral("pause"), paused); } bool MpvItem::isFileLoaded() const { return m_fileLoaded; } void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle) { if (!url.isValid()) { return; } m_translatedPaths.clear(); m_openingTranslatedPath = translatedSubtitle; m_openingTranslatedTitle = translatedTitle; if (!translatedSubtitle.isEmpty()) { m_translatedPaths.append(translatedSubtitle); } m_currentUrl = url; Q_EMIT currentUrlChanged(); if (!m_ready) { m_pendingUrl = url; return; } 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::stop() { commandAsync(QStringList{QStringLiteral("stop")}); m_currentUrl.clear(); Q_EMIT currentUrlChanged(); } void MpvItem::togglePause() { setPaused(!m_paused); } void MpvItem::seek(double seconds) { commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("absolute")}); } void MpvItem::seekBy(double seconds) { commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("relative")}); } void MpvItem::frameStep(bool forward) { commandAsync(QStringList{forward ? QStringLiteral("frame-step") : QStringLiteral("frame-back-step")}); } void MpvItem::screenshot() { commandAsync(QStringList{QStringLiteral("screenshot")}); } 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); } QVariantList MpvItem::audioTracks() const { return m_audioTracks; } int MpvItem::audioId() const { return m_audioId; } void MpvItem::setAudioId(int id) { setPropertyAsync(QStringLiteral("aid"), trackIdValue(id)); } int MpvItem::volume() const { return m_volume; } void MpvItem::setVolume(int volume) { volume = std::clamp(volume, 0, m_volumeMax); // Like sub-delay: update right away so repeated steps build on each other. Always // sent, since mpv may not have reported a volume from mpv.conf yet. if (volume != m_volume) { m_volume = volume; Q_EMIT volumeChanged(); } setPropertyAsync(QStringLiteral("volume"), volume); } int MpvItem::volumeMax() const { return m_volumeMax; } bool MpvItem::mute() const { return m_mute; } bool MpvItem::subtitleVisible() const { return m_subtitleVisible; } bool MpvItem::deinterlace() const { return m_deinterlace; } void MpvItem::setMute(bool mute) { setPropertyAsync(QStringLiteral("mute"), mute); } double MpvItem::speed() const { return m_speed; } void MpvItem::setSpeed(double speed) { const double rounded = std::round(std::clamp(speed, 0.1, 10.0) * 100.0) / 100.0; if (rounded != m_speed) { m_speed = rounded; Q_EMIT speedChanged(); } setPropertyAsync(QStringLiteral("speed"), rounded); } QVariantList MpvItem::chapters() const { return m_chapters; } int MpvItem::chapter() const { return m_chapter; } void MpvItem::setChapter(int chapter) { if (chapter >= 0 && chapter < m_chapters.size()) { setPropertyAsync(QStringLiteral("chapter"), chapter); } } void MpvItem::addSubtitleFile(const QUrl &url) { if (!url.isValid()) { return; } commandAsync(QStringList{QStringLiteral("sub-add"), localPathOrUrl(url), QStringLiteral("select")}); } void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title) { if (!url.isValid()) { return; } const QString path = localPathOrUrl(url); if (!m_translatedPaths.contains(path)) { m_translatedPaths.append(path); } for (const QVariant &entry : std::as_const(m_subtitleTracks)) { const QVariantMap track = entry.toMap(); if (track.value(QStringLiteral("externalFilename")).toString() == path) { commandAsync(QStringList{QStringLiteral("sub-reload"), QString::number(track.value(QStringLiteral("id")).toInt())}); showOnlySubtitle(track.value(QStringLiteral("id")).toInt()); return; } } // "auto" adds the track without selecting it; updateTracks() selects it once mpv lists it. m_pendingTranslatedPath = path; commandAsync(QStringList{QStringLiteral("sub-add"), path, QStringLiteral("auto"), title}); } int MpvItem::translationSourceId() const { QVariantList sources; for (const QVariant &entry : std::as_const(m_subtitleTracks)) { if (!isTranslatedSubtitle(entry.toMap().value(QStringLiteral("id")).toInt())) { sources.append(entry); } } // English first: it is the usual original, and dubbed releases often default to // subtitles translated from it. return TrackSelection::pickTranslationSource(selectionTracks(sources), {QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)}, isTranslatedSubtitle(m_subtitleId) ? 0 : m_subtitleId); } bool MpvItem::isTranslatedSubtitle(int id) const { return id > 0 && m_translatedPaths.contains(subtitlePath(m_subtitleTracks, id)); } void MpvItem::selectInitialTracks() { // The observed track-list may still describe the previous file; read it directly. updateTracks(getProperty(QStringLiteral("track-list")).toList()); // mpv finds a translation next to the video by itself (sub-auto); one kept in the // cache is added here, before choosing, so it can be chosen. const QString translated = std::exchange(m_openingTranslatedPath, {}); int translationId = subtitleIdForPath(m_subtitleTracks, translated); if (!translated.isEmpty() && translationId == 0 && QFileInfo::exists(translated)) { commandBlocking(QStringList{QStringLiteral("sub-add"), translated, QStringLiteral("auto"), m_openingTranslatedTitle, filenameLanguage(translated)}); updateTracks(getProperty(QStringLiteral("track-list")).toList()); translationId = subtitleIdForPath(m_subtitleTracks, translated); } const int audioId = TrackSelection::pickAudio(selectionTracks(m_audioTracks), m_preferredAudioLanguages, m_audioId); if (audioId != m_audioId) { setAudioId(audioId); } // Subtitles are for reading: the user's own language, then English, before the // audio language. Dubbed releases often default to a dub with matching subtitles. const QStringList fallbackLanguages{QLocale::languageToCode(QLocale::system().language()), QStringLiteral("en"), trackLanguage(m_audioTracks, audioId)}; const int subtitleId = TrackSelection::pickSubtitle(selectionTracks(m_subtitleTracks), m_preferredSubtitleLanguages, fallbackLanguages, m_subtitleId, m_autoSelectSubtitle, translationId); if (subtitleId != m_subtitleId) { setSubtitleId(subtitleId); } } void MpvItem::showOnlySubtitle(int id) { setSubtitleId(id); setSecondarySubtitleId(0); } bool MpvItem::loadUserConfig() const { return m_loadUserConfig; } void MpvItem::setLoadUserConfig(bool load) { if (load != m_loadUserConfig) { m_loadUserConfig = load; Q_EMIT loadUserConfigChanged(); } } QString MpvItem::userConfigDir() const { return MpvUserConfig::configDir(); } QStringList MpvItem::loadedUserConfigFiles() const { return m_loadedUserConfigFiles; } QStringList MpvItem::skippedUserOptions() const { return m_skippedUserOptions; } void MpvItem::applyUserConfig() { if (m_loadUserConfig) { const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/mpv"); QDir().mkpath(cacheDir); const QStringList files = MpvUserConfig::configFiles(); for (qsizetype i = 0; i < files.size(); ++i) { QFile source(files.at(i)); if (!source.open(QIODevice::ReadOnly)) { continue; } const MpvUserConfig::Prepared prepared = MpvUserConfig::prepare(QString::fromUtf8(source.readAll()), MpvUserConfig::configDir()); // mpv only loads config from files, so the adapted copy goes to the cache. QSaveFile copy(QStringLiteral("%1/mpv-%2.conf").arg(cacheDir).arg(i)); if (!copy.open(QIODevice::WriteOnly) || copy.write(prepared.content.toUtf8()) < 0 || !copy.commit()) { continue; } commandAsync(QStringList{QStringLiteral("load-config-file"), copy.fileName()}); m_loadedUserConfigFiles.append(files.at(i)); for (const QString &option : prepared.skippedOptions) { if (!m_skippedUserOptions.contains(option)) { m_skippedUserOptions.append(option); } } } } Q_EMIT userConfigApplied(); } void MpvItem::onPropertyChanged(const QString &property, const QVariant &value) { if (property == u"media-title") { m_mediaTitle = value.toString(); Q_EMIT mediaTitleChanged(); } else if (property == u"time-pos") { m_position = value.toDouble(); Q_EMIT positionChanged(); } else if (property == u"duration") { m_duration = value.toDouble(); Q_EMIT durationChanged(); } 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(); } else if (property == u"volume") { m_volume = value.toInt(); Q_EMIT volumeChanged(); } else if (property == u"volume-max") { m_volumeMax = value.toInt(); Q_EMIT volumeMaxChanged(); } else if (property == u"mute") { m_mute = value.toBool(); Q_EMIT muteChanged(); } else if (property == u"sub-visibility") { m_subtitleVisible = value.toBool(); Q_EMIT subtitleVisibleChanged(); } else if (property == u"deinterlace") { m_deinterlace = value.toBool(); Q_EMIT deinterlaceChanged(); } else if (property == u"speed") { m_speed = value.toDouble(); Q_EMIT speedChanged(); } else if (property == u"chapter-list") { QVariantList chapters; for (const QVariant &entry : value.toList()) { const QVariantMap chapter = entry.toMap(); chapters.append(QVariantMap{ {QStringLiteral("title"), chapter.value(QStringLiteral("title")).toString()}, {QStringLiteral("time"), chapter.value(QStringLiteral("time")).toDouble()}, }); } m_chapters = chapters; Q_EMIT chaptersChanged(); } else if (property == u"chapter") { // Unavailable (no chapters) arrives as an invalid value. m_chapter = value.isValid() ? value.toInt() : -1; Q_EMIT chapterChanged(); } else if (property == u"eof-reached") { if (value.toBool()) { Q_EMIT endOfFile(); } } } void MpvItem::updateTracks(const QVariantList &trackList) { QVariantList subtitles; QVariantList audio; int primary = 0; int secondary = 0; int audioId = 0; for (const QVariant &entry : trackList) { const QVariantMap track = entry.toMap(); const QString type = track.value(QStringLiteral("type")).toString(); const int id = track.value(QStringLiteral("id")).toInt(); if (type == u"audio") { if (track.value(QStringLiteral("selected")).toBool()) { audioId = id; } audio.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("forced"), track.value(QStringLiteral("forced")).toBool()}, {QStringLiteral("default"), track.value(QStringLiteral("default")).toBool()}, }); continue; } if (type != u"sub") { continue; } 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("bitmap"), TrackSelection::isBitmapSubtitle(track.value(QStringLiteral("codec")).toString())}, {QStringLiteral("forced"), track.value(QStringLiteral("forced")).toBool()}, {QStringLiteral("default"), track.value(QStringLiteral("default")).toBool()}, {QStringLiteral("external"), track.value(QStringLiteral("external")).toBool()}, {QStringLiteral("externalFilename"), track.value(QStringLiteral("external-filename")).toString()}, {QStringLiteral("ffIndex"), track.value(QStringLiteral("ff-index"), -1).toInt()}, }); if (!m_pendingTranslatedPath.isEmpty() && track.value(QStringLiteral("external-filename")).toString() == m_pendingTranslatedPath) { m_pendingTranslatedPath.clear(); showOnlySubtitle(id); } } m_subtitleTracks = subtitles; m_subtitleId = primary; m_secondarySubtitleId = secondary; Q_EMIT subtitleTracksChanged(); m_audioTracks = audio; m_audioId = audioId; Q_EMIT audioTracksChanged(); } void MpvItem::setFileLoaded(bool loaded) { if (loaded == m_fileLoaded) { return; } m_fileLoaded = loaded; Q_EMIT fileLoadedChanged(); }