From 77adb22edffcc893c6f66e5446a1a36de0ff5ea7 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 18:42:49 -0400 Subject: [PATCH 01/28] Add native video sync proposal --- docs/video-sync-native-proposal.md | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/video-sync-native-proposal.md diff --git a/docs/video-sync-native-proposal.md b/docs/video-sync-native-proposal.md new file mode 100644 index 0000000000000..766395db50ae0 --- /dev/null +++ b/docs/video-sync-native-proposal.md @@ -0,0 +1,69 @@ +# Native Video Sync Proposal + +## Goal + +Add a native reference-video workflow to MuseScore Studio so users can compose against picture, keep video playback aligned with the score playhead, and control video audio through MuseScore playback controls. + +## Initial Scope + +The first implementation should stay intentionally narrow: + +- attach one local video file to a score, +- store video path and offset metadata with the score, +- show a dockable video panel, +- sync play, pause, stop, and seek to MuseScore playback, +- provide video volume and mute controls. + +Mixer-level solo and full audio-graph routing should follow after the playback and score-persistence model is accepted. + +## User Workflow + +1. The user chooses an Attach Video action. +2. MuseScore stores a reference to the selected video and opens a Video panel. +3. When score playback starts, the video starts at the matching score time plus offset. +4. When playback pauses or seeks, the video follows. +5. The user can nudge offset in milliseconds while working. +6. If the video is missing, MuseScore asks the user to relink it. + +## Architecture Direction + +### Score Metadata + +Store score-level attachment data for: + +- video file path, +- offset in milliseconds, +- volume, +- mute state, +- future relink identity such as file hash or original file name. + +Prefer relative paths when the video is near the score file. Do not embed video data by default. + +### Playback Sync + +Add a video sync controller that listens to transport state and maps score playback position to video time. The controller should own drift correction rather than putting timing behavior in QML. + +### UI + +Add a dockable Video panel that uses the existing UI/dock registration patterns. The panel should be able to attach, detach, relink, play/pause through the global transport, and nudge offset. + +### Audio + +The MVP can begin with video-local mute and volume if needed. The release-quality target is a real Video source in MuseScore's audio/mixer layer so mute and solo behave like other playback channels. + +## Open Questions + +- Which existing score-project metadata layer is preferred for external media attachments? +- Which playback service should be the source of truth for elapsed score time? +- Is Qt Multimedia acceptable for the video decode/display layer on all supported platforms? +- Should video support be hidden behind an experimental flag while audio routing is incomplete? +- Should video audio participate in audio export, or should export be a later milestone? + +## Suggested Patch Sequence + +1. Add score/project metadata for an optional video attachment. +2. Add an inert internal video sync model and tests for persistence. +3. Add the Video panel UI. +4. Wire the panel to playback transport. +5. Add audio controls. +6. Add proper mixer/audio routing. From 293350583afb61d76bc2a0b9f82c774690f17f6e Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 18:51:07 -0400 Subject: [PATCH 02/28] Add project video attachment settings --- src/engraving/infrastructure/mscreader.cpp | 5 + src/engraving/infrastructure/mscreader.h | 1 + src/engraving/infrastructure/mscwriter.cpp | 5 + src/engraving/infrastructure/mscwriter.h | 1 + src/project/CMakeLists.txt | 3 + src/project/inotationproject.h | 2 + src/project/internal/notationproject.cpp | 28 +++++ src/project/internal/notationproject.h | 3 + src/project/internal/projectvideosettings.cpp | 113 ++++++++++++++++++ src/project/internal/projectvideosettings.h | 57 +++++++++ src/project/iprojectvideosettings.h | 70 +++++++++++ src/project/tests/CMakeLists.txt | 1 + .../tests/projectvideosettingstest.cpp | 87 ++++++++++++++ 13 files changed, 376 insertions(+) create mode 100644 src/project/internal/projectvideosettings.cpp create mode 100644 src/project/internal/projectvideosettings.h create mode 100644 src/project/iprojectvideosettings.h create mode 100644 src/project/tests/projectvideosettingstest.cpp diff --git a/src/engraving/infrastructure/mscreader.cpp b/src/engraving/infrastructure/mscreader.cpp index 94f2466456963..c65f92e64ea78 100644 --- a/src/engraving/infrastructure/mscreader.cpp +++ b/src/engraving/infrastructure/mscreader.cpp @@ -243,6 +243,11 @@ ByteArray MscReader::readAudioSettingsJsonFile(const muse::io::path_t& pathPrefi return fileData(pathPrefix.toString() + u"audiosettings.json"); } +ByteArray MscReader::readVideoSettingsJsonFile() const +{ + return fileData(u"videosettings.json"); +} + ByteArray MscReader::readViewSettingsJsonFile(const muse::io::path_t& pathPrefix) const { return fileData(pathPrefix.toString() + u"viewsettings.json"); diff --git a/src/engraving/infrastructure/mscreader.h b/src/engraving/infrastructure/mscreader.h index 3e0fb4c68bcd1..e0abe2c8aff96 100644 --- a/src/engraving/infrastructure/mscreader.h +++ b/src/engraving/infrastructure/mscreader.h @@ -71,6 +71,7 @@ class MscReader muse::ByteArray readAudioFile() const; muse::ByteArray readAudioSettingsJsonFile(const muse::io::path_t& pathPrefix = "") const; + muse::ByteArray readVideoSettingsJsonFile() const; muse::ByteArray readViewSettingsJsonFile(const muse::io::path_t& pathPrefix = "") const; muse::ByteArray readAutomationJsonFile() const; diff --git a/src/engraving/infrastructure/mscwriter.cpp b/src/engraving/infrastructure/mscwriter.cpp index 80ba132cf4a87..58d421e7783aa 100644 --- a/src/engraving/infrastructure/mscwriter.cpp +++ b/src/engraving/infrastructure/mscwriter.cpp @@ -198,6 +198,11 @@ void MscWriter::writeAudioSettingsJsonFile(const ByteArray& data, const muse::io addFileData(pathPrefix.toString() + u"audiosettings.json", data); } +void MscWriter::writeVideoSettingsJsonFile(const ByteArray& data) +{ + addFileData(u"videosettings.json", data); +} + void MscWriter::writeViewSettingsJsonFile(const ByteArray& data, const muse::io::path_t& pathPrefix) { addFileData(pathPrefix.toString() + u"viewsettings.json", data); diff --git a/src/engraving/infrastructure/mscwriter.h b/src/engraving/infrastructure/mscwriter.h index faba493c6c565..69cee76d43a60 100644 --- a/src/engraving/infrastructure/mscwriter.h +++ b/src/engraving/infrastructure/mscwriter.h @@ -66,6 +66,7 @@ class MscWriter void addImageFile(const muse::String& fileName, const muse::ByteArray& data); void writeAudioFile(const muse::ByteArray& data); void writeAudioSettingsJsonFile(const muse::ByteArray& data, const muse::io::path_t& pathPrefix = ""); + void writeVideoSettingsJsonFile(const muse::ByteArray& data); void writeViewSettingsJsonFile(const muse::ByteArray& data, const muse::io::path_t& pathPrefix = ""); void writeAutomationJsonFile(const muse::ByteArray& data); diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt index 1ed94a21bc7b6..f5778f999c4f1 100644 --- a/src/project/CMakeLists.txt +++ b/src/project/CMakeLists.txt @@ -35,6 +35,7 @@ target_sources(project PRIVATE inotationwriter.h inotationwritersregister.h iprojectaudiosettings.h + iprojectvideosettings.h iprojectfilescontroller.h iprojectconfiguration.h irecentfilescontroller.h @@ -51,6 +52,8 @@ target_sources(project PRIVATE internal/projectcreator.h internal/projectaudiosettings.cpp internal/projectaudiosettings.h + internal/projectvideosettings.cpp + internal/projectvideosettings.h internal/notationreadersregister.cpp internal/notationreadersregister.h internal/notationwritersregister.cpp diff --git a/src/project/inotationproject.h b/src/project/inotationproject.h index 5924509d94fd1..2ae15d7e05d2c 100644 --- a/src/project/inotationproject.h +++ b/src/project/inotationproject.h @@ -29,6 +29,7 @@ #include "global/async/channel.h" #include "iprojectaudiosettings.h" +#include "iprojectvideosettings.h" #include "notation/imasternotation.h" #include "types/projecttypes.h" @@ -80,6 +81,7 @@ class INotationProject virtual notation::IMasterNotationPtr masterNotation() const = 0; virtual IProjectAudioSettingsPtr audioSettings() const = 0; + virtual IProjectVideoSettingsPtr videoSettings() const = 0; }; using INotationProjectPtr = std::shared_ptr; diff --git a/src/project/internal/notationproject.cpp b/src/project/internal/notationproject.cpp index 3d25793f47e11..552517106e824 100644 --- a/src/project/internal/notationproject.cpp +++ b/src/project/internal/notationproject.cpp @@ -47,6 +47,7 @@ #include "notation/internal/masternotation.h" #include "notation/notationerrors.h" #include "projectaudiosettings.h" +#include "projectvideosettings.h" #include "projectfileinfoprovider.h" #include "projecterrors.h" @@ -92,6 +93,7 @@ QString NotationProject::scoreDefaultTitle() NotationProject::~NotationProject() { m_projectAudioSettings = nullptr; + m_projectVideoSettings = nullptr; m_masterNotation = nullptr; m_engravingProject = nullptr; } @@ -104,6 +106,7 @@ void NotationProject::setupProject() m_engravingProject->setFileInfoProvider(std::make_shared(this)); m_masterNotation = std::shared_ptr(new MasterNotation(this, iocContext())); m_projectAudioSettings = std::shared_ptr(new ProjectAudioSettings(iocContext())); + m_projectVideoSettings = std::make_shared(); } Ret NotationProject::load(const muse::io::path_t& path, const OpenParams& openParams, const std::string& format_) @@ -229,6 +232,12 @@ Ret NotationProject::doLoad(const muse::io::path_t& path, const OpenParams& open } } + // Load video settings + ret = m_projectVideoSettings->read(reader); + if (!ret) { + m_projectVideoSettings->makeDefault(); + } + // Load cloud info { m_cloudInfo.sourceUrl = masterScore->metaTags()[SOURCE_TAG].toQString(); @@ -328,6 +337,7 @@ Ret NotationProject::doImport(const muse::io::path_t& path, const OpenParams& op // Setup audio settings m_projectAudioSettings->makeDefault(); + m_projectVideoSettings->makeDefault(); // Setup view state m_masterNotation->notation()->viewState()->makeDefault(); @@ -374,6 +384,7 @@ Ret NotationProject::createNew(const ProjectCreateOptions& projectOptions) // Setup audio settings m_projectAudioSettings->makeDefault(); + m_projectVideoSettings->makeDefault(); // Setup view state m_masterNotation->notation()->viewState()->makeDefault(); @@ -901,6 +912,13 @@ Ret NotationProject::writeProject(MscWriter& msczWriter, bool createThumbnail, c return ret; } + // Write master video settings + ret = m_projectVideoSettings->write(msczWriter); + if (!ret) { + LOGE() << "failed write project video settings, err: " << ret.toString(); + return ret; + } + // Write master view settings m_masterNotation->notation()->viewState()->write(msczWriter); @@ -1094,6 +1112,11 @@ void NotationProject::listenIfNeedSaveChanges() markAsUnsaved(); m_hasNonUndoStackChanges = true; }); + + m_projectVideoSettings->settingsChanged().onNotify(this, [this]() { + markAsUnsaved(); + m_hasNonUndoStackChanges = true; + }); } void NotationProject::markAsSaved(const muse::io::path_t& path) @@ -1259,3 +1282,8 @@ IProjectAudioSettingsPtr NotationProject::audioSettings() const { return m_projectAudioSettings; } + +IProjectVideoSettingsPtr NotationProject::videoSettings() const +{ + return m_projectVideoSettings; +} diff --git a/src/project/internal/notationproject.h b/src/project/internal/notationproject.h index dc24385b1d95b..808f156e53279 100644 --- a/src/project/internal/notationproject.h +++ b/src/project/internal/notationproject.h @@ -36,6 +36,7 @@ #include "notation/inotationconfiguration.h" #include "projectaudiosettings.h" +#include "projectvideosettings.h" #include "iprojectmigrator.h" #include "global/iglobalconfiguration.h" @@ -112,6 +113,7 @@ class NotationProject : public INotationProject, public muse::Contextable, publi notation::IMasterNotationPtr masterNotation() const override; IProjectAudioSettingsPtr audioSettings() const override; + IProjectVideoSettingsPtr videoSettings() const override; private: void setupProject(); @@ -140,6 +142,7 @@ class NotationProject : public INotationProject, public muse::Contextable, publi mu::engraving::EngravingProjectPtr m_engravingProject = nullptr; notation::IMasterNotationPtr m_masterNotation = nullptr; ProjectAudioSettingsPtr m_projectAudioSettings = nullptr; + ProjectVideoSettingsPtr m_projectVideoSettings = nullptr; mutable CloudProjectInfo m_cloudInfo; mutable CloudAudioInfo m_cloudAudioInfo; diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp new file mode 100644 index 0000000000000..bdfc38312d726 --- /dev/null +++ b/src/project/internal/projectvideosettings.cpp @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "projectvideosettings.h" + +#include +#include + +#include "types/bytearray.h" + +using namespace mu::project; +using namespace muse; + +static constexpr int VIDEO_SETTINGS_VERSION = 1; + +const VideoAttachmentSettings& ProjectVideoSettings::attachment() const +{ + return m_attachment; +} + +void ProjectVideoSettings::setAttachment(const VideoAttachmentSettings& attachment) +{ + if (m_attachment == attachment) { + return; + } + + m_attachment = attachment; + m_settingsChanged.notify(); +} + +void ProjectVideoSettings::clearAttachment() +{ + setAttachment(VideoAttachmentSettings()); +} + +muse::async::Notification ProjectVideoSettings::settingsChanged() const +{ + return m_settingsChanged; +} + +muse::Ret ProjectVideoSettings::read(const engraving::MscReader& reader) +{ + ByteArray json = reader.readVideoSettingsJsonFile(); + if (json.empty()) { + makeDefault(); + return make_ret(Ret::Code::Ok); + } + + QJsonObject rootObj = QJsonDocument::fromJson(json.toQByteArrayNoCopy()).object(); + m_attachment = attachmentFromJson(rootObj.value("attachment").toObject()); + + return make_ret(Ret::Code::Ok); +} + +muse::Ret ProjectVideoSettings::write(engraving::MscWriter& writer) const +{ + if (!m_attachment.isValid()) { + return make_ret(Ret::Code::Ok); + } + + QJsonObject rootObj; + rootObj["version"] = VIDEO_SETTINGS_VERSION; + rootObj["attachment"] = attachmentToJson(m_attachment); + + QByteArray json = QJsonDocument(rootObj).toJson(); + writer.writeVideoSettingsJsonFile(ByteArray::fromQByteArrayNoCopy(json)); + + return make_ret(Ret::Code::Ok); +} + +void ProjectVideoSettings::makeDefault() +{ + m_attachment = VideoAttachmentSettings(); +} + +VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObject& object) const +{ + VideoAttachmentSettings result; + result.path = object.value("path").toString(); + result.offsetMs = object.value("offsetMs").toInt(); + result.volume = static_cast(object.value("volume").toDouble(1.0)); + result.muted = object.value("muted").toBool(false); + return result; +} + +QJsonObject ProjectVideoSettings::attachmentToJson(const VideoAttachmentSettings& attachment) const +{ + QJsonObject object; + object["path"] = attachment.path.toQString(); + object["offsetMs"] = attachment.offsetMs; + object["volume"] = attachment.volume; + object["muted"] = attachment.muted; + return object; +} diff --git a/src/project/internal/projectvideosettings.h b/src/project/internal/projectvideosettings.h new file mode 100644 index 0000000000000..8aa1c39d1185e --- /dev/null +++ b/src/project/internal/projectvideosettings.h @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "types/ret.h" +#include "engraving/infrastructure/mscreader.h" +#include "engraving/infrastructure/mscwriter.h" + +#include "../iprojectvideosettings.h" + +namespace mu::project { +class ProjectVideoSettings : public IProjectVideoSettings +{ +public: + const VideoAttachmentSettings& attachment() const override; + void setAttachment(const VideoAttachmentSettings& attachment) override; + void clearAttachment() override; + + muse::async::Notification settingsChanged() const override; + + muse::Ret read(const engraving::MscReader& reader); + muse::Ret write(engraving::MscWriter& writer) const; + + void makeDefault(); + +private: + VideoAttachmentSettings attachmentFromJson(const QJsonObject& object) const; + QJsonObject attachmentToJson(const VideoAttachmentSettings& attachment) const; + + VideoAttachmentSettings m_attachment; + muse::async::Notification m_settingsChanged; +}; + +using ProjectVideoSettingsPtr = std::shared_ptr; +} diff --git a/src/project/iprojectvideosettings.h b/src/project/iprojectvideosettings.h new file mode 100644 index 0000000000000..62abea5f0a446 --- /dev/null +++ b/src/project/iprojectvideosettings.h @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "async/notification.h" +#include "global/io/path.h" + +namespace mu::project { +struct VideoAttachmentSettings +{ + muse::io::path_t path; + int offsetMs = 0; + float volume = 1.f; + bool muted = false; + + bool isValid() const + { + return !path.empty(); + } + + bool operator==(const VideoAttachmentSettings& other) const + { + return path == other.path + && offsetMs == other.offsetMs + && volume == other.volume + && muted == other.muted; + } + + bool operator!=(const VideoAttachmentSettings& other) const + { + return !(*this == other); + } +}; + +class IProjectVideoSettings +{ +public: + virtual ~IProjectVideoSettings() = default; + + virtual const VideoAttachmentSettings& attachment() const = 0; + virtual void setAttachment(const VideoAttachmentSettings& attachment) = 0; + virtual void clearAttachment() = 0; + + virtual muse::async::Notification settingsChanged() const = 0; +}; + +using IProjectVideoSettingsPtr = std::shared_ptr; +} diff --git a/src/project/tests/CMakeLists.txt b/src/project/tests/CMakeLists.txt index 49de27c719e7b..494dd0d5132ad 100644 --- a/src/project/tests/CMakeLists.txt +++ b/src/project/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/mocks/mscmetareadermock.h ${CMAKE_CURRENT_LIST_DIR}/mocks/projectconfigurationmock.h ${CMAKE_CURRENT_LIST_DIR}/mscmetareadertests.cpp + ${CMAKE_CURRENT_LIST_DIR}/projectvideosettingstest.cpp ${CMAKE_CURRENT_LIST_DIR}/templatesrepositorytest.cpp ) diff --git a/src/project/tests/projectvideosettingstest.cpp b/src/project/tests/projectvideosettingstest.cpp new file mode 100644 index 0000000000000..368f8b5166df3 --- /dev/null +++ b/src/project/tests/projectvideosettingstest.cpp @@ -0,0 +1,87 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include + +#include "engraving/infrastructure/mscreader.h" +#include "engraving/infrastructure/mscwriter.h" +#include "project/internal/projectvideosettings.h" + +using namespace muse; +using namespace mu::engraving; +using namespace mu::project; + +TEST(ProjectVideoSettingsTests, MissingSettingsReadAsDefault) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + MscReader::Params readerParams; + readerParams.filePath = dir.path(); + readerParams.mode = MscIoMode::Dir; + + MscReader reader(readerParams); + ASSERT_TRUE(reader.open()); + + ProjectVideoSettings settings; + EXPECT_TRUE(settings.read(reader)); + + EXPECT_FALSE(settings.attachment().isValid()); +} + +TEST(ProjectVideoSettingsTests, WriteAndReadAttachment) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + MscWriter::Params writerParams; + writerParams.filePath = dir.path(); + writerParams.mode = MscIoMode::Dir; + + MscWriter writer(writerParams); + EXPECT_TRUE(writer.open()); + + ProjectVideoSettings source; + VideoAttachmentSettings attachment; + attachment.path = "media/reference-picture.mp4"; + attachment.offsetMs = -1250; + attachment.volume = 0.75f; + attachment.muted = true; + + source.setAttachment(attachment); + EXPECT_TRUE(source.write(writer)); + writer.close(); + + MscReader::Params readerParams; + readerParams.filePath = dir.path(); + readerParams.mode = MscIoMode::Dir; + + MscReader reader(readerParams); + ASSERT_TRUE(reader.open()); + + ProjectVideoSettings restored; + EXPECT_TRUE(restored.read(reader)); + + EXPECT_EQ(restored.attachment(), attachment); +} From 8cbeb29566b70d10e3061d721ce33ddf6d1eda68 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 19:36:04 -0400 Subject: [PATCH 03/28] Add native video panel shell --- src/appshell/appshelltypes.h | 1 + .../internal/applicationuiactions.cpp | 8 + .../AppShell/NotationPage/NotationPage.qml | 30 +++ .../qml/MuseScore/AppShell/appmenumodel.cpp | 1 + .../MuseScore/AppShell/notationpagemodel.cpp | 5 + .../MuseScore/AppShell/notationpagemodel.h | 1 + .../qml/MuseScore/Playback/CMakeLists.txt | 3 + .../qml/MuseScore/Playback/VideoPanel.qml | 222 ++++++++++++++++++ .../MuseScore/Playback/videopanelmodel.cpp | 174 ++++++++++++++ .../qml/MuseScore/Playback/videopanelmodel.h | 77 ++++++ 10 files changed, 522 insertions(+) create mode 100644 src/playback/qml/MuseScore/Playback/VideoPanel.qml create mode 100644 src/playback/qml/MuseScore/Playback/videopanelmodel.cpp create mode 100644 src/playback/qml/MuseScore/Playback/videopanelmodel.h diff --git a/src/appshell/appshelltypes.h b/src/appshell/appshelltypes.h index fc4e09af8f725..23775c7e34576 100644 --- a/src/appshell/appshelltypes.h +++ b/src/appshell/appshelltypes.h @@ -42,6 +42,7 @@ static const DockName MIXER_PANEL_NAME("mixerPanel"); static const DockName PIANO_KEYBOARD_PANEL_NAME("pianoKeyboardPanel"); static const DockName TIMELINE_PANEL_NAME("timelinePanel"); static const DockName PERCUSSION_PANEL_NAME("percussionPanel"); +static const DockName VIDEO_PANEL_NAME("videoPanel"); // Toolbars: static const DockName NOTATION_TOOLBAR_NAME("notationToolBar"); diff --git a/src/appshell/internal/applicationuiactions.cpp b/src/appshell/internal/applicationuiactions.cpp index 99f3ea0ef70d3..d862972cb5150 100644 --- a/src/appshell/internal/applicationuiactions.cpp +++ b/src/appshell/internal/applicationuiactions.cpp @@ -200,6 +200,13 @@ const UiActionList ApplicationUiActions::m_actions = { TranslatableString("action", "Show/hide piano keyboard"), Checkable::Yes ), + UiAction("toggle-video-panel", + mu::context::UiCtxProjectOpened, + mu::context::CTX_ANY, + TranslatableString("action", "&Video"), + TranslatableString("action", "Show/hide video panel"), + Checkable::Yes + ), UiAction(TOGGLE_PERCUSSION_PANEL_ACTION_CODE, mu::context::UiCtxProjectOpened, mu::context::CTX_NOTATION_OPENED, @@ -395,6 +402,7 @@ const QMap& ApplicationUiActions::toggleDockActions() { "toggle-timeline", TIMELINE_PANEL_NAME }, { "toggle-mixer", MIXER_PANEL_NAME }, { "toggle-piano-keyboard", PIANO_KEYBOARD_PANEL_NAME }, + { "toggle-video-panel", VIDEO_PANEL_NAME }, { TOGGLE_PERCUSSION_PANEL_ACTION_CODE, PERCUSSION_PANEL_NAME }, { "toggle-statusbar", NOTATION_STATUSBAR_NAME }, diff --git a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml index 422ccdf63c822..1be11a23d7725 100644 --- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml +++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml @@ -562,6 +562,36 @@ DockPage { } } } + }, + + DockPanel { + id: videoPanel + + objectName: root.pageModel.videoPanelName() + title: qsTrc("appshell", "Video") + + height: 260 + minimumHeight: root.horizontalPanelMinHeight + maximumHeight: root.horizontalPanelMaxHeight + + minimumWidth: root.panelMinDimension + maximumWidth: root.panelMaxDimension + + groupName: root.horizontalPanelsGroup + + //! NOTE: hidden by default + visible: false + + location: Location.Bottom + + dropDestinations: root.horizontalPanelDropDestinations + + navigationSection: root.navigationPanelSec(videoPanel.location) + + VideoPanel { + navigationSection: videoPanel.navigationSection + contentNavigationPanelOrderStart: videoPanel.contentNavigationPanelOrderStart + } } ] diff --git a/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp b/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp index 2a5de8330306e..298408a8af71f 100644 --- a/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp +++ b/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp @@ -308,6 +308,7 @@ MenuItem* AppMenuModel::makeViewMenu() makeMenuItem("toggle-timeline"), makeMenuItem("toggle-mixer"), makeMenuItem("toggle-piano-keyboard"), + makeMenuItem("toggle-video-panel"), makeMenuItem("toggle-percussion-panel"), makeMenuItem("playback-setup"), //makeMenuItem("toggle-scorecmp-tool"), // not implemented diff --git a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp index b388d418891a4..a4cbaff3be268 100644 --- a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp +++ b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp @@ -159,6 +159,11 @@ QString NotationPageModel::percussionPanelName() const return PERCUSSION_PANEL_NAME; } +QString NotationPageModel::videoPanelName() const +{ + return VIDEO_PANEL_NAME; +} + QString NotationPageModel::statusBarName() const { return NOTATION_STATUSBAR_NAME; diff --git a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h index 47e46089143d2..8762ed762a4f0 100644 --- a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h +++ b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h @@ -78,6 +78,7 @@ class NotationPageModel : public QObject, public muse::Contextable, public muse: Q_INVOKABLE QString pianoKeyboardPanelName() const; Q_INVOKABLE QString timelinePanelName() const; Q_INVOKABLE QString percussionPanelName() const; + Q_INVOKABLE QString videoPanelName() const; Q_INVOKABLE QString statusBarName() const; diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index 279583f143e4d..64a4ac8dda019 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -51,6 +51,8 @@ qt_add_qml_module(playback_qml soundflagsettingsmodel.h soundprofilesmodel.cpp soundprofilesmodel.h + videopanelmodel.cpp + videopanelmodel.h QML_FILES internal/AudioProcessingProgressBar.qml internal/AudioResourceControl.qml @@ -81,6 +83,7 @@ qt_add_qml_module(playback_qml PlaybackToolBar.qml SoundFlagPopup.qml SoundProfilesDialog.qml + VideoPanel.qml IMPORTS TARGET muse_ui_qml TARGET muse_uicomponents_qml diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml new file mode 100644 index 0000000000000..29b28711f22cb --- /dev/null +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -0,0 +1,222 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +import Muse.Ui +import Muse.UiComponents +import MuseScore.Playback + +Item { + id: root + + property NavigationSection navigationSection: null + property int contentNavigationPanelOrderStart: 0 + + VideoPanelModel { + id: videoModel + } + + Component.onCompleted: { + videoModel.load() + } + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 16 + + Rectangle { + Layout.preferredWidth: 280 + Layout.fillHeight: true + Layout.minimumHeight: 136 + + radius: 4 + color: ui.theme.backgroundSecondaryColor + border.width: ui.theme.borderWidth + border.color: ui.theme.strokeColor + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + StyledIconLabel { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 12 + iconCode: IconCode.PLAY + font.pixelSize: 34 + opacity: videoModel.hasVideo ? 1.0 : 0.45 + } + + StyledTextLabel { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: videoModel.hasVideo ? videoModel.videoPath : qsTrc("playback", "No video attached") + maximumLineCount: 3 + wrapMode: Text.WrapAnywhere + displayTruncatedTextOnHover: true + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + FilePicker { + Layout.fillWidth: true + path: videoModel.videoPath + dialogTitle: qsTrc("playback", "Choose video") + filter: qsTrc("playback", "Video files (*.mp4 *.mov *.m4v *.avi *.mkv *.webm);;All files (*)") + buttonType: FlatButton.Horizontal + navigation: navigationPanel + navigationRowOrderStart: root.contentNavigationPanelOrderStart + + onPathEdited: function(newPath) { + videoModel.videoPath = newPath + } + } + + RowLayout { + spacing: 8 + + StyledTextLabel { + text: qsTrc("playback", "Offset") + } + + FlatButton { + text: qsTrc("playback", "-100 ms") + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 2 + + onClicked: { + videoModel.nudgeOffset(-100) + } + } + + TextInputField { + Layout.preferredWidth: 88 + currentText: videoModel.offsetMs.toString() + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 3 + + onTextEditingFinished: function(newTextValue) { + var parsedValue = parseInt(newTextValue) + if (!isNaN(parsedValue)) { + videoModel.offsetMs = parsedValue + } + } + } + + StyledTextLabel { + text: qsTrc("playback", "ms") + } + + FlatButton { + text: qsTrc("playback", "+100 ms") + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 4 + + onClicked: { + videoModel.nudgeOffset(100) + } + } + } + + RowLayout { + spacing: 12 + + CheckBox { + text: qsTrc("playback", "Mute") + checked: videoModel.muted + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 5 + + onClicked: { + videoModel.muted = !videoModel.muted + } + } + + StyledTextLabel { + text: qsTrc("playback", "Volume") + enabled: videoModel.hasVideo + } + + StyledSlider { + Layout.preferredWidth: 180 + from: 0 + to: 100 + stepSize: 1 + value: videoModel.volumePercent + enabled: videoModel.hasVideo && !videoModel.muted + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 6 + + onMoved: { + videoModel.volumePercent = value + } + } + + StyledTextLabel { + text: videoModel.volumePercent + "%" + enabled: videoModel.hasVideo + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + FlatButton { + text: qsTrc("playback", "Clear") + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 7 + + onClicked: { + videoModel.clearVideo() + } + } + } + } + } + + NavigationPanel { + id: navigationPanel + name: "VideoPanel" + section: root.navigationSection + order: root.contentNavigationPanelOrderStart + direction: NavigationPanel.Vertical + } +} diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp new file mode 100644 index 0000000000000..0e836abc4cef0 --- /dev/null +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -0,0 +1,174 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "videopanelmodel.h" + +#include + +using namespace mu::playback; +using namespace mu::project; + +VideoPanelModel::VideoPanelModel(QObject* parent) + : QObject(parent), muse::Contextable(muse::iocCtxForQmlObject(this)) +{ +} + +void VideoPanelModel::load() +{ + context()->currentProjectChanged().onNotify(this, [this]() { + listenCurrentProject(); + emit videoSettingsChanged(); + }); + + listenCurrentProject(); + emit videoSettingsChanged(); +} + +void VideoPanelModel::clearVideo() +{ + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + settings->clearAttachment(); +} + +void VideoPanelModel::nudgeOffset(int deltaMs) +{ + setOffsetMs(offsetMs() + deltaMs); +} + +bool VideoPanelModel::hasVideo() const +{ + return attachment().isValid(); +} + +QString VideoPanelModel::videoPath() const +{ + return attachment().path.toQString(); +} + +void VideoPanelModel::setVideoPath(const QString& path) +{ + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + if (path.isEmpty()) { + settings->clearAttachment(); + return; + } + + VideoAttachmentSettings updated = attachment(); + updated.path = path; + updateAttachment(updated); +} + +int VideoPanelModel::offsetMs() const +{ + return attachment().offsetMs; +} + +void VideoPanelModel::setOffsetMs(int offsetMs) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || updated.offsetMs == offsetMs) { + return; + } + + updated.offsetMs = offsetMs; + updateAttachment(updated); +} + +int VideoPanelModel::volumePercent() const +{ + return std::clamp(static_cast(attachment().volume * 100.f + 0.5f), 0, 100); +} + +void VideoPanelModel::setVolumePercent(int volumePercent) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid()) { + return; + } + + volumePercent = std::clamp(volumePercent, 0, 100); + const float volume = static_cast(volumePercent) / 100.f; + if (updated.volume == volume) { + return; + } + + updated.volume = volume; + updateAttachment(updated); +} + +bool VideoPanelModel::muted() const +{ + return attachment().muted; +} + +void VideoPanelModel::setMuted(bool muted) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || updated.muted == muted) { + return; + } + + updated.muted = muted; + updateAttachment(updated); +} + +IProjectVideoSettingsPtr VideoPanelModel::videoSettings() const +{ + INotationProjectPtr project = context()->currentProject(); + return project ? project->videoSettings() : nullptr; +} + +VideoAttachmentSettings VideoPanelModel::attachment() const +{ + IProjectVideoSettingsPtr settings = videoSettings(); + return settings ? settings->attachment() : VideoAttachmentSettings(); +} + +void VideoPanelModel::updateAttachment(const VideoAttachmentSettings& attachment) +{ + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + settings->setAttachment(attachment); +} + +void VideoPanelModel::listenCurrentProject() +{ + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + settings->settingsChanged().onNotify(this, [this]() { + emit videoSettingsChanged(); + }, Asyncable::Mode::SetReplace); +} diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h new file mode 100644 index 0000000000000..58193781f6039 --- /dev/null +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "async/asyncable.h" +#include "modularity/ioc.h" +#include "context/iglobalcontext.h" +#include "project/iprojectvideosettings.h" + +namespace mu::playback { +class VideoPanelModel : public QObject, public muse::Contextable, public muse::async::Asyncable +{ + Q_OBJECT + + Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY videoSettingsChanged) + Q_PROPERTY(QString videoPath READ videoPath WRITE setVideoPath NOTIFY videoSettingsChanged) + Q_PROPERTY(int offsetMs READ offsetMs WRITE setOffsetMs NOTIFY videoSettingsChanged) + Q_PROPERTY(int volumePercent READ volumePercent WRITE setVolumePercent NOTIFY videoSettingsChanged) + Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) + + QML_ELEMENT + + muse::ContextInject context = { this }; + +public: + explicit VideoPanelModel(QObject* parent = nullptr); + + Q_INVOKABLE void load(); + Q_INVOKABLE void clearVideo(); + Q_INVOKABLE void nudgeOffset(int deltaMs); + + bool hasVideo() const; + QString videoPath() const; + void setVideoPath(const QString& path); + + int offsetMs() const; + void setOffsetMs(int offsetMs); + + int volumePercent() const; + void setVolumePercent(int volumePercent); + + bool muted() const; + void setMuted(bool muted); + +signals: + void videoSettingsChanged(); + +private: + project::IProjectVideoSettingsPtr videoSettings() const; + project::VideoAttachmentSettings attachment() const; + void updateAttachment(const project::VideoAttachmentSettings& attachment); + void listenCurrentProject(); +}; +} From 1b67de09576d9baf3ea0837ec4d9e6d6aa42666d Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:16 -0400 Subject: [PATCH 04/28] Add embedded video playback to video panel --- src/CMakeLists.txt | 2 + src/app/configs/data/shortcuts.xml | 5 + src/app/configs/data/shortcuts_azerty.xml | 5 + src/app/configs/data/shortcuts_mac.xml | 5 + .../AppShell/NotationPage/NotationPage.qml | 4 +- .../qml/MuseScore/Playback/CMakeLists.txt | 3 + .../qml/MuseScore/Playback/VideoPanel.qml | 134 ++++++++++++++---- .../MuseScore/Playback/videopanelmodel.cpp | 6 + .../qml/MuseScore/Playback/videopanelmodel.h | 3 + 9 files changed, 138 insertions(+), 29 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4a5b9a7b26e67..dc283534b95cd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +find_package(Qt6 REQUIRED COMPONENTS Multimedia) + # Modules must be listed in dependency order if (MUE_BUILD_BRAILLE_MODULE) add_subdirectory(braille) diff --git a/src/app/configs/data/shortcuts.xml b/src/app/configs/data/shortcuts.xml index e47c53467b07e..226dfc4fa2490 100644 --- a/src/app/configs/data/shortcuts.xml +++ b/src/app/configs/data/shortcuts.xml @@ -767,6 +767,11 @@ F12 0 + + toggle-video-panel + Ctrl+Alt+V + 0 + quit Ctrl+Q diff --git a/src/app/configs/data/shortcuts_azerty.xml b/src/app/configs/data/shortcuts_azerty.xml index 1951b1d7e2bef..d27afb29843b3 100644 --- a/src/app/configs/data/shortcuts_azerty.xml +++ b/src/app/configs/data/shortcuts_azerty.xml @@ -808,6 +808,11 @@ F12 0 + + toggle-video-panel + Ctrl+Alt+V + 0 + quit Ctrl+Q diff --git a/src/app/configs/data/shortcuts_mac.xml b/src/app/configs/data/shortcuts_mac.xml index 94aa71776383a..4e42fdeab9313 100644 --- a/src/app/configs/data/shortcuts_mac.xml +++ b/src/app/configs/data/shortcuts_mac.xml @@ -767,6 +767,11 @@ F12 0 + + toggle-video-panel + Ctrl+Alt+V + 0 + quit Ctrl+Q diff --git a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml index 1be11a23d7725..2432698f43f6d 100644 --- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml +++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml @@ -570,8 +570,8 @@ DockPage { objectName: root.pageModel.videoPanelName() title: qsTrc("appshell", "Video") - height: 260 - minimumHeight: root.horizontalPanelMinHeight + height: 150 + minimumHeight: 96 maximumHeight: root.horizontalPanelMaxHeight minimumWidth: root.panelMinDimension diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index 64a4ac8dda019..f49f819090d19 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -85,9 +85,12 @@ qt_add_qml_module(playback_qml SoundProfilesDialog.qml VideoPanel.qml IMPORTS + QtMultimedia TARGET muse_ui_qml TARGET muse_uicomponents_qml TARGET notationscene_qml ) +target_link_libraries(playback_qml PRIVATE Qt6::Multimedia) + fixup_qml_module_dependencies(playback_qml) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 29b28711f22cb..e685f1819a011 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -24,6 +24,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import QtMultimedia import Muse.Ui import Muse.UiComponents @@ -35,6 +36,8 @@ Item { property NavigationSection navigationSection: null property int contentNavigationPanelOrderStart: 0 + clip: true + VideoPanelModel { id: videoModel } @@ -45,39 +48,74 @@ Item { RowLayout { anchors.fill: parent - anchors.margins: 12 - spacing: 16 + anchors.margins: 8 + spacing: 12 Rectangle { - Layout.preferredWidth: 280 + Layout.preferredWidth: 230 + Layout.maximumWidth: 280 Layout.fillHeight: true - Layout.minimumHeight: 136 + Layout.minimumHeight: 72 radius: 4 - color: ui.theme.backgroundSecondaryColor + color: "#111111" border.width: ui.theme.borderWidth border.color: ui.theme.strokeColor + clip: true + + Video { + id: video - ColumnLayout { anchors.fill: parent - anchors.margins: 12 - spacing: 8 + anchors.margins: 1 + source: videoModel.videoUrl + muted: videoModel.muted + volume: videoModel.volumePercent / 100 + fillMode: VideoOutput.PreserveAspectFit + visible: videoModel.hasVideo + + onSourceChanged: { + stop() + } + } + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width - 24, 200) + spacing: 6 + visible: !videoModel.hasVideo StyledIconLabel { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: 12 iconCode: IconCode.PLAY - font.pixelSize: 34 - opacity: videoModel.hasVideo ? 1.0 : 0.45 + font.pixelSize: 24 + opacity: 0.45 } StyledTextLabel { Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter - text: videoModel.hasVideo ? videoModel.videoPath : qsTrc("playback", "No video attached") - maximumLineCount: 3 - wrapMode: Text.WrapAnywhere - displayTruncatedTextOnHover: true + text: qsTrc("playback", "No video attached") + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + } + + FlatButton { + anchors.centerIn: parent + width: 44 + height: 44 + visible: videoModel.hasVideo && video.playbackState !== MediaPlayer.PlayingState + icon: IconCode.PLAY_FILL + buttonType: FlatButton.IconOnly + transparent: true + iconColor: "white" + toolTipTitle: qsTrc("playback", "Play video") + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 1 + + onClicked: { + video.play() } } } @@ -85,7 +123,52 @@ Item { ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 12 + Layout.minimumWidth: 360 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + FlatButton { + Layout.preferredWidth: 36 + Layout.preferredHeight: 30 + enabled: videoModel.hasVideo + icon: video.playbackState === MediaPlayer.PlayingState ? IconCode.PAUSE : IconCode.PLAY + buttonType: FlatButton.IconOnly + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 2 + + onClicked: { + if (video.playbackState === MediaPlayer.PlayingState) { + video.pause() + } else { + video.play() + } + } + } + + StyledSlider { + Layout.fillWidth: true + from: 0 + to: Math.max(video.duration, 1) + stepSize: 100 + value: video.position + enabled: videoModel.hasVideo && video.seekable + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 3 + + onMoved: { + video.seek(value) + } + } + + StyledTextLabel { + Layout.preferredWidth: 92 + horizontalAlignment: Text.AlignRight + text: videoModel.hasVideo ? Math.floor(video.position / 1000) + " / " + Math.floor(video.duration / 1000) + " s" : "" + } + } FilePicker { Layout.fillWidth: true @@ -94,7 +177,7 @@ Item { filter: qsTrc("playback", "Video files (*.mp4 *.mov *.m4v *.avi *.mkv *.webm);;All files (*)") buttonType: FlatButton.Horizontal navigation: navigationPanel - navigationRowOrderStart: root.contentNavigationPanelOrderStart + navigationRowOrderStart: root.contentNavigationPanelOrderStart + 4 onPathEdited: function(newPath) { videoModel.videoPath = newPath @@ -112,7 +195,7 @@ Item { text: qsTrc("playback", "-100 ms") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 2 + navigation.order: root.contentNavigationPanelOrderStart + 6 onClicked: { videoModel.nudgeOffset(-100) @@ -124,7 +207,7 @@ Item { currentText: videoModel.offsetMs.toString() enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 3 + navigation.order: root.contentNavigationPanelOrderStart + 7 onTextEditingFinished: function(newTextValue) { var parsedValue = parseInt(newTextValue) @@ -142,7 +225,7 @@ Item { text: qsTrc("playback", "+100 ms") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 4 + navigation.order: root.contentNavigationPanelOrderStart + 8 onClicked: { videoModel.nudgeOffset(100) @@ -158,7 +241,7 @@ Item { checked: videoModel.muted enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 5 + navigation.order: root.contentNavigationPanelOrderStart + 9 onClicked: { videoModel.muted = !videoModel.muted @@ -178,7 +261,7 @@ Item { value: videoModel.volumePercent enabled: videoModel.hasVideo && !videoModel.muted navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 6 + navigation.order: root.contentNavigationPanelOrderStart + 10 onMoved: { videoModel.volumePercent = value @@ -189,10 +272,6 @@ Item { text: videoModel.volumePercent + "%" enabled: videoModel.hasVideo } - } - - RowLayout { - Layout.fillWidth: true Item { Layout.fillWidth: true @@ -202,9 +281,10 @@ Item { text: qsTrc("playback", "Clear") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 7 + navigation.order: root.contentNavigationPanelOrderStart + 11 onClicked: { + video.stop() videoModel.clearVideo() } } diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index 0e836abc4cef0..c8f1686259c64 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -68,6 +68,12 @@ QString VideoPanelModel::videoPath() const return attachment().path.toQString(); } +QUrl VideoPanelModel::videoUrl() const +{ + const QString path = videoPath(); + return path.isEmpty() ? QUrl() : QUrl::fromLocalFile(path); +} + void VideoPanelModel::setVideoPath(const QString& path) { IProjectVideoSettingsPtr settings = videoSettings(); diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 58193781f6039..6cdf6b3f895cd 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -23,6 +23,7 @@ #pragma once #include +#include #include #include "async/asyncable.h" @@ -37,6 +38,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY videoSettingsChanged) Q_PROPERTY(QString videoPath READ videoPath WRITE setVideoPath NOTIFY videoSettingsChanged) + Q_PROPERTY(QUrl videoUrl READ videoUrl NOTIFY videoSettingsChanged) Q_PROPERTY(int offsetMs READ offsetMs WRITE setOffsetMs NOTIFY videoSettingsChanged) Q_PROPERTY(int volumePercent READ volumePercent WRITE setVolumePercent NOTIFY videoSettingsChanged) Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) @@ -54,6 +56,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a bool hasVideo() const; QString videoPath() const; + QUrl videoUrl() const; void setVideoPath(const QString& path); int offsetMs() const; From 5719dfdbba334f19f0ef75f27ad11e111f9e3cc6 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 22:12:23 -0400 Subject: [PATCH 05/28] Sync video panel playback to score transport --- .../qml/MuseScore/Playback/VideoPanel.qml | 41 +++++++++++++++++++ .../MuseScore/Playback/videopanelmodel.cpp | 30 ++++++++++++++ .../qml/MuseScore/Playback/videopanelmodel.h | 11 +++++ 3 files changed, 82 insertions(+) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index e685f1819a011..0d1efe3296b53 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -42,10 +42,47 @@ Item { id: videoModel } + readonly property int syncToleranceMs: 180 + Component.onCompleted: { videoModel.load() } + function targetVideoPositionMs() { + return Math.max(0, Math.min(video.duration, videoModel.scorePlaybackPositionMs + videoModel.offsetMs)) + } + + function syncVideoToScore(forceSeek) { + if (!videoModel.hasVideo || video.duration <= 0) { + return + } + + var targetPosition = targetVideoPositionMs() + if (forceSeek || Math.abs(video.position - targetPosition) > syncToleranceMs) { + video.seek(targetPosition) + } + + if (videoModel.scorePlaying) { + if (targetPosition < video.duration && video.playbackState !== MediaPlayer.PlayingState) { + video.play() + } + } else if (video.playbackState === MediaPlayer.PlayingState) { + video.pause() + } + } + + Connections { + target: videoModel + + function onPlaybackSyncChanged() { + root.syncVideoToScore(false) + } + + function onVideoSettingsChanged() { + root.syncVideoToScore(true) + } + } + RowLayout { anchors.fill: parent anchors.margins: 8 @@ -77,6 +114,10 @@ Item { onSourceChanged: { stop() } + + onDurationChanged: { + root.syncVideoToScore(true) + } } ColumnLayout { diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index c8f1686259c64..8625bf125712d 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -23,6 +23,7 @@ #include "videopanelmodel.h" #include +#include using namespace mu::playback; using namespace mu::project; @@ -34,6 +35,8 @@ VideoPanelModel::VideoPanelModel(QObject* parent) void VideoPanelModel::load() { + listenPlaybackController(); + context()->currentProjectChanged().onNotify(this, [this]() { listenCurrentProject(); emit videoSettingsChanged(); @@ -145,6 +148,16 @@ void VideoPanelModel::setMuted(bool muted) updateAttachment(updated); } +bool VideoPanelModel::scorePlaying() const +{ + return playbackController()->isPlaying(); +} + +int VideoPanelModel::scorePlaybackPositionMs() const +{ + return m_scorePlaybackPositionMs; +} + IProjectVideoSettingsPtr VideoPanelModel::videoSettings() const { INotationProjectPtr project = context()->currentProject(); @@ -178,3 +191,20 @@ void VideoPanelModel::listenCurrentProject() emit videoSettingsChanged(); }, Asyncable::Mode::SetReplace); } + +void VideoPanelModel::listenPlaybackController() +{ + playbackController()->isPlayingChanged().onNotify(this, [this]() { + emit playbackSyncChanged(); + }); + + playbackController()->currentPlaybackPositionChanged().onReceive(this, [this](muse::audio::secs_t pos, muse::midi::tick_t) { + const int positionMs = std::max(0, static_cast(std::lround(pos * 1000.0))); + if (m_scorePlaybackPositionMs == positionMs) { + return; + } + + m_scorePlaybackPositionMs = positionMs; + emit playbackSyncChanged(); + }); +} diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 6cdf6b3f895cd..ceae79944bfa0 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -30,6 +30,7 @@ #include "modularity/ioc.h" #include "context/iglobalcontext.h" #include "project/iprojectvideosettings.h" +#include "playback/iplaybackcontroller.h" namespace mu::playback { class VideoPanelModel : public QObject, public muse::Contextable, public muse::async::Asyncable @@ -42,10 +43,13 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_PROPERTY(int offsetMs READ offsetMs WRITE setOffsetMs NOTIFY videoSettingsChanged) Q_PROPERTY(int volumePercent READ volumePercent WRITE setVolumePercent NOTIFY videoSettingsChanged) Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) + Q_PROPERTY(bool scorePlaying READ scorePlaying NOTIFY playbackSyncChanged) + Q_PROPERTY(int scorePlaybackPositionMs READ scorePlaybackPositionMs NOTIFY playbackSyncChanged) QML_ELEMENT muse::ContextInject context = { this }; + muse::Inject playbackController = { this }; public: explicit VideoPanelModel(QObject* parent = nullptr); @@ -68,13 +72,20 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a bool muted() const; void setMuted(bool muted); + bool scorePlaying() const; + int scorePlaybackPositionMs() const; + signals: void videoSettingsChanged(); + void playbackSyncChanged(); private: project::IProjectVideoSettingsPtr videoSettings() const; project::VideoAttachmentSettings attachment() const; void updateAttachment(const project::VideoAttachmentSettings& attachment); void listenCurrentProject(); + void listenPlaybackController(); + + int m_scorePlaybackPositionMs = 0; }; } From 6fe4690c1d0dadf825eeaa27a039f433dd93ccc1 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 22:23:21 -0400 Subject: [PATCH 06/28] Move video audio controls into mixer --- .../qml/MuseScore/Playback/CMakeLists.txt | 1 + .../qml/MuseScore/Playback/MixerPanel.qml | 10 ++ .../qml/MuseScore/Playback/VideoPanel.qml | 41 +----- .../Playback/internal/VideoMixerSection.qml | 137 ++++++++++++++++++ .../MuseScore/Playback/videopanelmodel.cpp | 19 +++ .../qml/MuseScore/Playback/videopanelmodel.h | 4 + src/project/internal/projectvideosettings.cpp | 2 + src/project/iprojectvideosettings.h | 4 +- .../tests/projectvideosettingstest.cpp | 1 + 9 files changed, 179 insertions(+), 40 deletions(-) create mode 100644 src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index f49f819090d19..25d2924c4716b 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -69,6 +69,7 @@ qt_add_qml_module(playback_qml internal/MixerSoundSection.qml internal/MixerTitleSection.qml internal/MixerVolumeSection.qml + internal/VideoMixerSection.qml internal/PlaybackSpeedPopup.qml internal/PlaybackSpeedSlider.qml internal/PlaybackToolBarActions.qml diff --git a/src/playback/qml/MuseScore/Playback/MixerPanel.qml b/src/playback/qml/MuseScore/Playback/MixerPanel.qml index 0cbe7bccad710..cf9a133319109 100644 --- a/src/playback/qml/MuseScore/Playback/MixerPanel.qml +++ b/src/playback/qml/MuseScore/Playback/MixerPanel.qml @@ -342,6 +342,16 @@ ColumnLayout { prv.setNavigateControlIndex(index) } } + + VideoMixerSection { + id: videoMixerSection + + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth + navigationSection: root.navigationSection + navigationOrder: root.contentNavigationPanelOrderStart + 900 + } } } diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 0d1efe3296b53..e2c9756ef1193 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -275,44 +275,7 @@ Item { } RowLayout { - spacing: 12 - - CheckBox { - text: qsTrc("playback", "Mute") - checked: videoModel.muted - enabled: videoModel.hasVideo - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 9 - - onClicked: { - videoModel.muted = !videoModel.muted - } - } - - StyledTextLabel { - text: qsTrc("playback", "Volume") - enabled: videoModel.hasVideo - } - - StyledSlider { - Layout.preferredWidth: 180 - from: 0 - to: 100 - stepSize: 1 - value: videoModel.volumePercent - enabled: videoModel.hasVideo && !videoModel.muted - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 10 - - onMoved: { - videoModel.volumePercent = value - } - } - - StyledTextLabel { - text: videoModel.volumePercent + "%" - enabled: videoModel.hasVideo - } + Layout.fillWidth: true Item { Layout.fillWidth: true @@ -322,7 +285,7 @@ Item { text: qsTrc("playback", "Clear") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 11 + navigation.order: root.contentNavigationPanelOrderStart + 9 onClicked: { video.stop() diff --git a/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml b/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml new file mode 100644 index 0000000000000..c926a0c93d2c1 --- /dev/null +++ b/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml @@ -0,0 +1,137 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +import Muse.Ui +import Muse.UiComponents +import MuseScore.Playback + +Row { + id: root + + property bool headerVisible: true + property real headerWidth: 98 + property real channelItemWidth: 108 + property NavigationSection navigationSection: null + property int navigationOrder: 0 + + visible: videoModel.hasVideo + height: visible ? 44 : 0 + spacing: 1 + + VideoPanelModel { + id: videoModel + } + + Component.onCompleted: { + videoModel.load() + } + + StyledTextLabel { + width: root.headerVisible ? root.headerWidth : 0 + height: parent.height + visible: root.headerVisible + text: qsTrc("playback", "Video") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Rectangle { + width: root.channelItemWidth + height: parent.height + color: "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + FlatToggleButton { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + icon: IconCode.MUTE + checked: videoModel.muted + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.row: 1 + navigation.accessible.name: qsTrc("playback", "Video mute") + + onToggled: { + videoModel.muted = !checked + } + } + + FlatToggleButton { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + icon: IconCode.SOLO + checked: videoModel.solo + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.row: 2 + navigation.accessible.name: qsTrc("playback", "Video solo") + + onToggled: { + videoModel.solo = !videoModel.solo + } + } + + StyledSlider { + Layout.fillWidth: true + from: 0 + to: 100 + stepSize: 1 + value: videoModel.volumePercent + enabled: videoModel.hasVideo && !videoModel.muted + navigation.panel: navigationPanel + navigation.row: 3 + navigation.accessible.name: qsTrc("playback", "Video volume") + " " + videoModel.volumePercent + "%" + + onMoved: { + videoModel.volumePercent = value + } + } + } + + StyledTextLabel { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 8 + text: videoModel.volumePercent + "%" + font: ui.theme.captionFont + enabled: videoModel.hasVideo + } + } + + NavigationPanel { + id: navigationPanel + name: "VideoMixerPanel" + section: root.navigationSection + order: root.navigationOrder + direction: NavigationPanel.Horizontal + } +} diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index 8625bf125712d..acc1e1d994d1e 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -148,6 +148,25 @@ void VideoPanelModel::setMuted(bool muted) updateAttachment(updated); } +bool VideoPanelModel::solo() const +{ + return attachment().solo; +} + +void VideoPanelModel::setSolo(bool solo) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || updated.solo == solo) { + return; + } + + updated.solo = solo; + if (solo) { + updated.muted = false; + } + updateAttachment(updated); +} + bool VideoPanelModel::scorePlaying() const { return playbackController()->isPlaying(); diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index ceae79944bfa0..4252518581082 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -43,6 +43,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_PROPERTY(int offsetMs READ offsetMs WRITE setOffsetMs NOTIFY videoSettingsChanged) Q_PROPERTY(int volumePercent READ volumePercent WRITE setVolumePercent NOTIFY videoSettingsChanged) Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) + Q_PROPERTY(bool solo READ solo WRITE setSolo NOTIFY videoSettingsChanged) Q_PROPERTY(bool scorePlaying READ scorePlaying NOTIFY playbackSyncChanged) Q_PROPERTY(int scorePlaybackPositionMs READ scorePlaybackPositionMs NOTIFY playbackSyncChanged) @@ -72,6 +73,9 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a bool muted() const; void setMuted(bool muted); + bool solo() const; + void setSolo(bool solo); + bool scorePlaying() const; int scorePlaybackPositionMs() const; diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index bdfc38312d726..5bb9b1494f59c 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -99,6 +99,7 @@ VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObje result.offsetMs = object.value("offsetMs").toInt(); result.volume = static_cast(object.value("volume").toDouble(1.0)); result.muted = object.value("muted").toBool(false); + result.solo = object.value("solo").toBool(false); return result; } @@ -109,5 +110,6 @@ QJsonObject ProjectVideoSettings::attachmentToJson(const VideoAttachmentSettings object["offsetMs"] = attachment.offsetMs; object["volume"] = attachment.volume; object["muted"] = attachment.muted; + object["solo"] = attachment.solo; return object; } diff --git a/src/project/iprojectvideosettings.h b/src/project/iprojectvideosettings.h index 62abea5f0a446..dfd8984bdb4fe 100644 --- a/src/project/iprojectvideosettings.h +++ b/src/project/iprojectvideosettings.h @@ -34,6 +34,7 @@ struct VideoAttachmentSettings int offsetMs = 0; float volume = 1.f; bool muted = false; + bool solo = false; bool isValid() const { @@ -45,7 +46,8 @@ struct VideoAttachmentSettings return path == other.path && offsetMs == other.offsetMs && volume == other.volume - && muted == other.muted; + && muted == other.muted + && solo == other.solo; } bool operator!=(const VideoAttachmentSettings& other) const diff --git a/src/project/tests/projectvideosettingstest.cpp b/src/project/tests/projectvideosettingstest.cpp index 368f8b5166df3..5466d058b3a53 100644 --- a/src/project/tests/projectvideosettingstest.cpp +++ b/src/project/tests/projectvideosettingstest.cpp @@ -68,6 +68,7 @@ TEST(ProjectVideoSettingsTests, WriteAndReadAttachment) attachment.offsetMs = -1250; attachment.volume = 0.75f; attachment.muted = true; + attachment.solo = true; source.setAttachment(attachment); EXPECT_TRUE(source.write(writer)); From 8acf1097011428ac482858574d031e4be622df6d Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 22:40:52 -0400 Subject: [PATCH 07/28] Align video controls as mixer channel --- .../qml/MuseScore/Playback/CMakeLists.txt | 1 - .../qml/MuseScore/Playback/MixerPanel.qml | 366 ++++++++++++------ .../qml/MuseScore/Playback/VideoPanel.qml | 2 +- .../Playback/internal/VideoMixerSection.qml | 137 ------- 4 files changed, 252 insertions(+), 254 deletions(-) delete mode 100644 src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index 25d2924c4716b..f49f819090d19 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -69,7 +69,6 @@ qt_add_qml_module(playback_qml internal/MixerSoundSection.qml internal/MixerTitleSection.qml internal/MixerVolumeSection.qml - internal/VideoMixerSection.qml internal/PlaybackSpeedPopup.qml internal/PlaybackSpeedSlider.qml internal/PlaybackToolBarActions.qml diff --git a/src/playback/qml/MuseScore/Playback/MixerPanel.qml b/src/playback/qml/MuseScore/Playback/MixerPanel.qml index cf9a133319109..702dada28a142 100644 --- a/src/playback/qml/MuseScore/Playback/MixerPanel.qml +++ b/src/playback/qml/MuseScore/Playback/MixerPanel.qml @@ -49,7 +49,7 @@ ColumnLayout { spacing: 0 function resizePanelToContentHeight() { - if (contentColumn.completed && implicitHeight > 0) { + if (contentRow.completed && implicitHeight > 0) { root.resizeRequested(width, implicitHeight) } } @@ -117,6 +117,14 @@ ColumnLayout { } } + VideoPanelModel { + id: videoMixerModel + } + + Component.onCompleted: { + videoMixerModel.load() + } + MixerPanelContextMenuModel { id: contextMenuModel @@ -131,10 +139,10 @@ ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - contentWidth: contentColumn.width + 1 // for trailing separator - contentHeight: Math.max(contentColumn.height, height) + contentWidth: contentRow.width + 1 // for trailing separator + contentHeight: Math.max(contentRow.height, height) - implicitHeight: contentColumn.height + implicitHeight: contentRow.height interactive: (height < contentHeight || width < contentWidth) && !flickable.resourcePickingActive @@ -174,14 +182,15 @@ ColumnLayout { spacing: prv.channelItemWidth Repeater { - model: contextMenuModel.labelsSectionVisible ? mixerPanelModel.count + 1 : mixerPanelModel.count + model: contextMenuModel.labelsSectionVisible ? mixerPanelModel.count + (videoMixerModel.hasVideo ? 2 : 1) + : mixerPanelModel.count + (videoMixerModel.hasVideo ? 1 : 0) SeparatorLine { orientation: Qt.Vertical } } } - Column { - id: contentColumn + Row { + id: contentRow anchors.bottom: parent.bottom width: childrenRect.width @@ -190,167 +199,294 @@ ColumnLayout { property bool completed: false Component.onCompleted: { - contentColumn.completed = true + contentRow.completed = true } - MixerSoundSection { - id: soundSection + Column { + id: contentColumn + + width: childrenRect.width + spacing: 0 + + MixerSoundSection { + id: soundSection - visible: contextMenuModel.soundSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth - spacingAbove: 8 + visible: contextMenuModel.soundSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth + spacingAbove: 8 - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 1 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 1 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerFxSection { - id: fxSection + MixerFxSection { + id: fxSection - visible: contextMenuModel.audioFxSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth + visible: contextMenuModel.audioFxSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 100 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 100 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerAuxSendsSection { - id: auxSendsSection + MixerAuxSendsSection { + id: auxSendsSection - visible: contextMenuModel.auxSendsSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth + visible: contextMenuModel.auxSendsSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth + channelItemWidth: prv.channelItemWidth - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 200 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 200 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerBalanceSection { - id: balanceSection + MixerBalanceSection { + id: balanceSection - visible: contextMenuModel.balanceSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth + visible: contextMenuModel.balanceSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 300 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 300 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerVolumeSection { - id: volumeSection + MixerVolumeSection { + id: volumeSection - visible: contextMenuModel.volumeSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth + visible: contextMenuModel.volumeSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 400 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 400 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerFaderSection { - id: faderSection + MixerFaderSection { + id: faderSection - visible: contextMenuModel.faderSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth - spacingAbove: -3 - spacingBelow: -2 + visible: contextMenuModel.faderSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth + spacingAbove: -3 + spacingBelow: -2 - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 500 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 500 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerMuteAndSoloSection { - id: muteAndSoloSection + MixerMuteAndSoloSection { + id: muteAndSoloSection - visible: contextMenuModel.muteAndSoloSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth + visible: contextMenuModel.muteAndSoloSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 600 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 600 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } - } - MixerTitleSection { - id: titleSection + MixerTitleSection { + id: titleSection - visible: contextMenuModel.titleSectionVisible - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth - spacingAbove: 2 - spacingBelow: 0 + visible: contextMenuModel.titleSectionVisible + headerVisible: contextMenuModel.labelsSectionVisible + headerWidth: prv.headerWidth + channelItemWidth: prv.channelItemWidth + spacingAbove: 2 + spacingBelow: 0 - model: mixerPanelModel + model: mixerPanelModel - navigationRowStart: 700 - needReadChannelName: prv.isPanelActivated + navigationRowStart: 700 + needReadChannelName: prv.isPanelActivated - onNavigateControlIndexChanged: function(index) { - prv.setNavigateControlIndex(index) + onNavigateControlIndexChanged: function(index) { + prv.setNavigateControlIndex(index) + } } } - VideoMixerSection { - id: videoMixerSection + Column { + id: videoMixerChannel + + visible: videoMixerModel.hasVideo + width: visible ? prv.channelItemWidth : 0 + spacing: 0 + + Item { + width: parent.width + height: soundSection.visible ? soundSection.height : 0 + } - headerVisible: contextMenuModel.labelsSectionVisible - headerWidth: prv.headerWidth - channelItemWidth: prv.channelItemWidth - navigationSection: root.navigationSection - navigationOrder: root.contentNavigationPanelOrderStart + 900 + Item { + width: parent.width + height: fxSection.visible ? fxSection.height : 0 + } + + Item { + width: parent.width + height: auxSendsSection.visible ? auxSendsSection.height : 0 + } + + Item { + width: parent.width + height: balanceSection.visible ? balanceSection.height : 0 + } + + Item { + width: parent.width + height: volumeSection.visible ? volumeSection.height : 0 + + RowLayout { + anchors.centerIn: parent + width: parent.width - 12 + spacing: 4 + + StyledSlider { + Layout.fillWidth: true + from: 0 + to: 100 + stepSize: 1 + value: videoMixerModel.volumePercent + enabled: videoMixerModel.hasVideo && !videoMixerModel.muted + navigation.panel: videoNavigationPanel + navigation.row: 1 + navigation.accessible.name: qsTrc("playback", "Video volume") + " " + videoMixerModel.volumePercent + "%" + + onMoved: { + videoMixerModel.volumePercent = value + } + } + + StyledTextLabel { + Layout.preferredWidth: 32 + horizontalAlignment: Text.AlignRight + text: videoMixerModel.volumePercent + "%" + font: ui.theme.captionFont + } + } + } + + Item { + width: parent.width + height: faderSection.visible ? faderSection.height : 0 + } + + Item { + width: parent.width + height: muteAndSoloSection.visible ? muteAndSoloSection.height : 0 + + Row { + anchors.centerIn: parent + spacing: 6 + + FlatToggleButton { + height: 20 + width: 20 + icon: IconCode.MUTE + checked: videoMixerModel.muted + enabled: videoMixerModel.hasVideo + navigation.panel: videoNavigationPanel + navigation.row: 2 + navigation.accessible.name: qsTrc("playback", "Video mute") + + onToggled: { + videoMixerModel.muted = !checked + } + } + + FlatToggleButton { + height: 20 + width: 20 + icon: IconCode.SOLO + checked: videoMixerModel.solo + enabled: videoMixerModel.hasVideo + navigation.panel: videoNavigationPanel + navigation.row: 3 + navigation.accessible.name: qsTrc("playback", "Video solo") + + onToggled: { + videoMixerModel.solo = !videoMixerModel.solo + } + } + } + } + + Rectangle { + width: parent.width + height: titleSection.visible ? titleSection.height : 0 + color: Utils.colorWithAlpha(ui.theme.accentColor, 0.5) + border.color: ui.theme.accentColor + border.width: 1 + + StyledTextLabel { + anchors.centerIn: parent + width: parent.width - 12 + text: qsTrc("playback", "Video") + font: ui.theme.bodyBoldFont + } + } + + NavigationPanel { + id: videoNavigationPanel + name: "VideoMixerPanel" + section: root.navigationSection + order: root.contentNavigationPanelOrderStart + mixerPanelModel.count + 1 + direction: NavigationPanel.Horizontal + } } } } diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index e2c9756ef1193..7b9e8b8adee5b 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -216,7 +216,7 @@ Item { path: videoModel.videoPath dialogTitle: qsTrc("playback", "Choose video") filter: qsTrc("playback", "Video files (*.mp4 *.mov *.m4v *.avi *.mkv *.webm);;All files (*)") - buttonType: FlatButton.Horizontal + buttonType: FlatButton.IconOnly navigation: navigationPanel navigationRowOrderStart: root.contentNavigationPanelOrderStart + 4 diff --git a/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml b/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml deleted file mode 100644 index c926a0c93d2c1..0000000000000 --- a/src/playback/qml/MuseScore/Playback/internal/VideoMixerSection.qml +++ /dev/null @@ -1,137 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-Studio-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts - -import Muse.Ui -import Muse.UiComponents -import MuseScore.Playback - -Row { - id: root - - property bool headerVisible: true - property real headerWidth: 98 - property real channelItemWidth: 108 - property NavigationSection navigationSection: null - property int navigationOrder: 0 - - visible: videoModel.hasVideo - height: visible ? 44 : 0 - spacing: 1 - - VideoPanelModel { - id: videoModel - } - - Component.onCompleted: { - videoModel.load() - } - - StyledTextLabel { - width: root.headerVisible ? root.headerWidth : 0 - height: parent.height - visible: root.headerVisible - text: qsTrc("playback", "Video") - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - Rectangle { - width: root.channelItemWidth - height: parent.height - color: "transparent" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 6 - - FlatToggleButton { - Layout.preferredWidth: 20 - Layout.preferredHeight: 20 - icon: IconCode.MUTE - checked: videoModel.muted - enabled: videoModel.hasVideo - navigation.panel: navigationPanel - navigation.row: 1 - navigation.accessible.name: qsTrc("playback", "Video mute") - - onToggled: { - videoModel.muted = !checked - } - } - - FlatToggleButton { - Layout.preferredWidth: 20 - Layout.preferredHeight: 20 - icon: IconCode.SOLO - checked: videoModel.solo - enabled: videoModel.hasVideo - navigation.panel: navigationPanel - navigation.row: 2 - navigation.accessible.name: qsTrc("playback", "Video solo") - - onToggled: { - videoModel.solo = !videoModel.solo - } - } - - StyledSlider { - Layout.fillWidth: true - from: 0 - to: 100 - stepSize: 1 - value: videoModel.volumePercent - enabled: videoModel.hasVideo && !videoModel.muted - navigation.panel: navigationPanel - navigation.row: 3 - navigation.accessible.name: qsTrc("playback", "Video volume") + " " + videoModel.volumePercent + "%" - - onMoved: { - videoModel.volumePercent = value - } - } - } - - StyledTextLabel { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.rightMargin: 8 - text: videoModel.volumePercent + "%" - font: ui.theme.captionFont - enabled: videoModel.hasVideo - } - } - - NavigationPanel { - id: navigationPanel - name: "VideoMixerPanel" - section: root.navigationSection - order: root.navigationOrder - direction: NavigationPanel.Horizontal - } -} From 727cedc9e853fbab36d63e1fd4cc397734a4975a Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 22:57:53 -0400 Subject: [PATCH 08/28] Represent video as mixer channel --- .../qml/MuseScore/Playback/MixerPanel.qml | 162 +----------------- .../Playback/internal/MixerTitleSection.qml | 2 + .../qml/MuseScore/Playback/mixerchannelitem.h | 1 + .../MuseScore/Playback/mixerpanelmodel.cpp | 102 +++++++++++ .../qml/MuseScore/Playback/mixerpanelmodel.h | 3 + .../MuseScore/Playback/videopanelmodel.cpp | 22 +++ .../qml/MuseScore/Playback/videopanelmodel.h | 4 + src/project/internal/projectvideosettings.cpp | 2 + src/project/iprojectvideosettings.h | 2 + .../tests/projectvideosettingstest.cpp | 1 + 10 files changed, 147 insertions(+), 154 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/MixerPanel.qml b/src/playback/qml/MuseScore/Playback/MixerPanel.qml index 702dada28a142..edab950bd5276 100644 --- a/src/playback/qml/MuseScore/Playback/MixerPanel.qml +++ b/src/playback/qml/MuseScore/Playback/MixerPanel.qml @@ -49,7 +49,7 @@ ColumnLayout { spacing: 0 function resizePanelToContentHeight() { - if (contentRow.completed && implicitHeight > 0) { + if (contentColumn.completed && implicitHeight > 0) { root.resizeRequested(width, implicitHeight) } } @@ -117,14 +117,6 @@ ColumnLayout { } } - VideoPanelModel { - id: videoMixerModel - } - - Component.onCompleted: { - videoMixerModel.load() - } - MixerPanelContextMenuModel { id: contextMenuModel @@ -139,10 +131,10 @@ ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - contentWidth: contentRow.width + 1 // for trailing separator - contentHeight: Math.max(contentRow.height, height) + contentWidth: contentColumn.width + 1 // for trailing separator + contentHeight: Math.max(contentColumn.height, height) - implicitHeight: contentRow.height + implicitHeight: contentColumn.height interactive: (height < contentHeight || width < contentWidth) && !flickable.resourcePickingActive @@ -182,15 +174,14 @@ ColumnLayout { spacing: prv.channelItemWidth Repeater { - model: contextMenuModel.labelsSectionVisible ? mixerPanelModel.count + (videoMixerModel.hasVideo ? 2 : 1) - : mixerPanelModel.count + (videoMixerModel.hasVideo ? 1 : 0) + model: contextMenuModel.labelsSectionVisible ? mixerPanelModel.count + 1 : mixerPanelModel.count SeparatorLine { orientation: Qt.Vertical } } } - Row { - id: contentRow + Column { + id: contentColumn anchors.bottom: parent.bottom width: childrenRect.width @@ -199,15 +190,9 @@ ColumnLayout { property bool completed: false Component.onCompleted: { - contentRow.completed = true + contentColumn.completed = true } - Column { - id: contentColumn - - width: childrenRect.width - spacing: 0 - MixerSoundSection { id: soundSection @@ -357,137 +342,6 @@ ColumnLayout { prv.setNavigateControlIndex(index) } } - } - - Column { - id: videoMixerChannel - - visible: videoMixerModel.hasVideo - width: visible ? prv.channelItemWidth : 0 - spacing: 0 - - Item { - width: parent.width - height: soundSection.visible ? soundSection.height : 0 - } - - Item { - width: parent.width - height: fxSection.visible ? fxSection.height : 0 - } - - Item { - width: parent.width - height: auxSendsSection.visible ? auxSendsSection.height : 0 - } - - Item { - width: parent.width - height: balanceSection.visible ? balanceSection.height : 0 - } - - Item { - width: parent.width - height: volumeSection.visible ? volumeSection.height : 0 - - RowLayout { - anchors.centerIn: parent - width: parent.width - 12 - spacing: 4 - - StyledSlider { - Layout.fillWidth: true - from: 0 - to: 100 - stepSize: 1 - value: videoMixerModel.volumePercent - enabled: videoMixerModel.hasVideo && !videoMixerModel.muted - navigation.panel: videoNavigationPanel - navigation.row: 1 - navigation.accessible.name: qsTrc("playback", "Video volume") + " " + videoMixerModel.volumePercent + "%" - - onMoved: { - videoMixerModel.volumePercent = value - } - } - - StyledTextLabel { - Layout.preferredWidth: 32 - horizontalAlignment: Text.AlignRight - text: videoMixerModel.volumePercent + "%" - font: ui.theme.captionFont - } - } - } - - Item { - width: parent.width - height: faderSection.visible ? faderSection.height : 0 - } - - Item { - width: parent.width - height: muteAndSoloSection.visible ? muteAndSoloSection.height : 0 - - Row { - anchors.centerIn: parent - spacing: 6 - - FlatToggleButton { - height: 20 - width: 20 - icon: IconCode.MUTE - checked: videoMixerModel.muted - enabled: videoMixerModel.hasVideo - navigation.panel: videoNavigationPanel - navigation.row: 2 - navigation.accessible.name: qsTrc("playback", "Video mute") - - onToggled: { - videoMixerModel.muted = !checked - } - } - - FlatToggleButton { - height: 20 - width: 20 - icon: IconCode.SOLO - checked: videoMixerModel.solo - enabled: videoMixerModel.hasVideo - navigation.panel: videoNavigationPanel - navigation.row: 3 - navigation.accessible.name: qsTrc("playback", "Video solo") - - onToggled: { - videoMixerModel.solo = !videoMixerModel.solo - } - } - } - } - - Rectangle { - width: parent.width - height: titleSection.visible ? titleSection.height : 0 - color: Utils.colorWithAlpha(ui.theme.accentColor, 0.5) - border.color: ui.theme.accentColor - border.width: 1 - - StyledTextLabel { - anchors.centerIn: parent - width: parent.width - 12 - text: qsTrc("playback", "Video") - font: ui.theme.bodyBoldFont - } - } - - NavigationPanel { - id: videoNavigationPanel - name: "VideoMixerPanel" - section: root.navigationSection - order: root.contentNavigationPanelOrderStart + mixerPanelModel.count + 1 - direction: NavigationPanel.Horizontal - } - } } } diff --git a/src/playback/qml/MuseScore/Playback/internal/MixerTitleSection.qml b/src/playback/qml/MuseScore/Playback/internal/MixerTitleSection.qml index ffef8a914331a..ae85d09030d4c 100644 --- a/src/playback/qml/MuseScore/Playback/internal/MixerTitleSection.qml +++ b/src/playback/qml/MuseScore/Playback/internal/MixerTitleSection.qml @@ -48,6 +48,8 @@ MixerPanelSection { return ui.theme.accentColor case MixerChannelItem.Aux: return "#63D47B" + case MixerChannelItem.Video: + return "#3B94E5" case MixerChannelItem.Master: return "#F87BDC" } diff --git a/src/playback/qml/MuseScore/Playback/mixerchannelitem.h b/src/playback/qml/MuseScore/Playback/mixerchannelitem.h index 888e9da7e631d..40329e8dc7c8a 100644 --- a/src/playback/qml/MuseScore/Playback/mixerchannelitem.h +++ b/src/playback/qml/MuseScore/Playback/mixerchannelitem.h @@ -81,6 +81,7 @@ class MixerChannelItem : public QObject, public muse::async::Asyncable, public m PrimaryInstrument, SecondaryInstrument, Metronome, + Video, Aux, Master, }; diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index 721954da8ed6d..4b294cf2914ef 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -22,6 +22,9 @@ #include "mixerpanelmodel.h" +#include +#include + #include "async/notifylist.h" #include "defer.h" @@ -38,6 +41,18 @@ using namespace mu::notation; using namespace mu::project; static constexpr int INVALID_INDEX = -1; +static constexpr TrackId VIDEO_TRACK_ID = -2; + +static volume_db_t videoVolumeToDb(float volume) +{ + volume = std::clamp(volume, 0.f, 1.f); + return volume <= 0.f ? volume_db_t::make(-60.f) : muse::linear_to_db(muse::ratio_t::make(volume)); +} + +static float videoVolumeFromDb(volume_db_t volume) +{ + return std::clamp(muse::db_to_linear(volume).raw(), 0.f, 1.f); +} MixerPanelModel::MixerPanelModel(QObject* parent) : QAbstractListModel(parent), muse::Contextable(muse::iocCtxForQmlObject(this)) @@ -158,6 +173,10 @@ void MixerPanelModel::reloadItems() } } + if (videoSettings() && videoSettings()->attachment().isValid()) { + m_mixerChannelList.push_back(buildVideoChannelItem()); + } + addInstrumentTrack(notationPlayback()->metronomeTrackId()); const auto& auxTrackIdMap = controller()->auxTrackIdMap(); @@ -323,6 +342,30 @@ void MixerPanelModel::setupConnections() removeItem(trackId); } }); + + if (videoSettings()) { + videoSettings()->settingsChanged().onNotify(this, [this]() { + IProjectVideoSettingsPtr settings = videoSettings(); + MixerChannelItem* item = findChannelItem(VIDEO_TRACK_ID); + const bool hasVideo = settings && settings->attachment().isValid(); + if (hasVideo != (item != nullptr)) { + reload(); + return; + } + + if (!hasVideo || !item) { + return; + } + + const VideoAttachmentSettings& attachment = settings->attachment(); + AudioOutputParams outParams; + outParams.volume = videoVolumeToDb(attachment.volume); + outParams.balance = attachment.balance; + outParams.muted = attachment.muted; + outParams.solo = attachment.solo; + loadOutputParams(item, std::move(outParams)); + }, Asyncable::Mode::SetReplace); + } } int MixerPanelModel::resolveInsertIndex(const engraving::InstrumentTrackId& newInstrumentTrackId) const @@ -538,6 +581,60 @@ MixerChannelItem* MixerPanelModel::buildAuxChannelItem(aux_channel_idx_t index, return item; } +MixerChannelItem* MixerPanelModel::buildVideoChannelItem() +{ + MixerChannelItem* item = new MixerChannelItem(this, MixerChannelItem::Type::Video, true /*outputOnly*/, VIDEO_TRACK_ID); + item->setPanelSection(m_navigationSection); + item->setTitle(muse::qtrc("playback", "Video")); + + project::VideoAttachmentSettings attachment = videoSettings()->attachment(); + + AudioOutputParams outParams; + outParams.volume = videoVolumeToDb(attachment.volume); + outParams.balance = attachment.balance; + outParams.muted = attachment.muted; + outParams.solo = attachment.solo; + loadOutputParams(item, std::move(outParams)); + + connect(item, &MixerChannelItem::controlParamsChanged, this, [this](const AudioOutputParams& params) { + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + VideoAttachmentSettings updated = settings->attachment(); + if (!updated.isValid()) { + return; + } + + updated.volume = videoVolumeFromDb(params.volume); + updated.balance = params.balance; + updated.muted = params.muted; + settings->setAttachment(updated); + }); + + connect(item, &MixerChannelItem::soloMuteStateChanged, this, [this](const notation::INotationSoloMuteState::SoloMuteState& state) { + IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings) { + return; + } + + VideoAttachmentSettings updated = settings->attachment(); + if (!updated.isValid()) { + return; + } + + updated.muted = state.mute; + updated.solo = state.solo; + if (updated.solo) { + updated.muted = false; + } + settings->setAttachment(updated); + }); + + return item; +} + MixerChannelItem* MixerPanelModel::buildMasterChannelItem() { MixerChannelItem* item = new MixerChannelItem(this, MixerChannelItem::Type::Master, true /*outputOnly*/); @@ -630,6 +727,11 @@ IProjectAudioSettingsPtr MixerPanelModel::audioSettings() const return currentProject() ? currentProject()->audioSettings() : nullptr; } +IProjectVideoSettingsPtr MixerPanelModel::videoSettings() const +{ + return currentProject() ? currentProject()->videoSettings() : nullptr; +} + INotationPlaybackPtr MixerPanelModel::notationPlayback() const { return currentProject() ? currentProject()->masterNotation()->playback() : nullptr; diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h index 3fc7bb592bbc6..f1c21a3bfb649 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h @@ -33,6 +33,7 @@ #include "context/iglobalcontext.h" #include "playback/iplaybackconfiguration.h" #include "project/iprojectaudiosettings.h" +#include "project/iprojectvideosettings.h" #include "ui/qml/Muse/Ui/navigationsection.h" #include "iplaybackcontroller.h" @@ -100,6 +101,7 @@ class MixerPanelModel : public QAbstractListModel, public QQmlParserStatus, publ MixerChannelItem* buildInstrumentChannelItem(const muse::audio::TrackId trackId, const engraving::InstrumentTrackId& instrumentTrackId, bool isPrimary = true); + MixerChannelItem* buildVideoChannelItem(); MixerChannelItem* buildAuxChannelItem(muse::audio::aux_channel_idx_t index, const muse::audio::TrackId trackId); MixerChannelItem* buildMasterChannelItem(); @@ -112,6 +114,7 @@ class MixerPanelModel : public QAbstractListModel, public QQmlParserStatus, publ project::INotationProjectPtr currentProject() const; project::IProjectAudioSettingsPtr audioSettings() const; + project::IProjectVideoSettingsPtr videoSettings() const; notation::INotationPlaybackPtr notationPlayback() const; notation::INotationPartsPtr masterNotationParts() const; diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index acc1e1d994d1e..9286e64b4f934 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -132,6 +132,28 @@ void VideoPanelModel::setVolumePercent(int volumePercent) updateAttachment(updated); } +int VideoPanelModel::balance() const +{ + return std::clamp(static_cast(attachment().balance * 100.f + (attachment().balance >= 0.f ? 0.5f : -0.5f)), -100, 100); +} + +void VideoPanelModel::setBalance(int balance) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid()) { + return; + } + + balance = std::clamp(balance, -100, 100); + const float scaledBalance = static_cast(balance) / 100.f; + if (updated.balance == scaledBalance) { + return; + } + + updated.balance = scaledBalance; + updateAttachment(updated); +} + bool VideoPanelModel::muted() const { return attachment().muted; diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 4252518581082..864f8e1f2bb2a 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -42,6 +42,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_PROPERTY(QUrl videoUrl READ videoUrl NOTIFY videoSettingsChanged) Q_PROPERTY(int offsetMs READ offsetMs WRITE setOffsetMs NOTIFY videoSettingsChanged) Q_PROPERTY(int volumePercent READ volumePercent WRITE setVolumePercent NOTIFY videoSettingsChanged) + Q_PROPERTY(int balance READ balance WRITE setBalance NOTIFY videoSettingsChanged) Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) Q_PROPERTY(bool solo READ solo WRITE setSolo NOTIFY videoSettingsChanged) Q_PROPERTY(bool scorePlaying READ scorePlaying NOTIFY playbackSyncChanged) @@ -70,6 +71,9 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a int volumePercent() const; void setVolumePercent(int volumePercent); + int balance() const; + void setBalance(int balance); + bool muted() const; void setMuted(bool muted); diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index 5bb9b1494f59c..6b919cad05060 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -98,6 +98,7 @@ VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObje result.path = object.value("path").toString(); result.offsetMs = object.value("offsetMs").toInt(); result.volume = static_cast(object.value("volume").toDouble(1.0)); + result.balance = static_cast(object.value("balance").toDouble(0.0)); result.muted = object.value("muted").toBool(false); result.solo = object.value("solo").toBool(false); return result; @@ -109,6 +110,7 @@ QJsonObject ProjectVideoSettings::attachmentToJson(const VideoAttachmentSettings object["path"] = attachment.path.toQString(); object["offsetMs"] = attachment.offsetMs; object["volume"] = attachment.volume; + object["balance"] = attachment.balance; object["muted"] = attachment.muted; object["solo"] = attachment.solo; return object; diff --git a/src/project/iprojectvideosettings.h b/src/project/iprojectvideosettings.h index dfd8984bdb4fe..6b66174a9243d 100644 --- a/src/project/iprojectvideosettings.h +++ b/src/project/iprojectvideosettings.h @@ -33,6 +33,7 @@ struct VideoAttachmentSettings muse::io::path_t path; int offsetMs = 0; float volume = 1.f; + float balance = 0.f; bool muted = false; bool solo = false; @@ -46,6 +47,7 @@ struct VideoAttachmentSettings return path == other.path && offsetMs == other.offsetMs && volume == other.volume + && balance == other.balance && muted == other.muted && solo == other.solo; } diff --git a/src/project/tests/projectvideosettingstest.cpp b/src/project/tests/projectvideosettingstest.cpp index 5466d058b3a53..f332e9ccb263b 100644 --- a/src/project/tests/projectvideosettingstest.cpp +++ b/src/project/tests/projectvideosettingstest.cpp @@ -67,6 +67,7 @@ TEST(ProjectVideoSettingsTests, WriteAndReadAttachment) attachment.path = "media/reference-picture.mp4"; attachment.offsetMs = -1250; attachment.volume = 0.75f; + attachment.balance = -0.25f; attachment.muted = true; attachment.solo = true; From 204f0c88da0dc213c839dfe1ffaf6dd61f4b9f55 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 23:01:25 -0400 Subject: [PATCH 09/28] Make video panel responsive in bottom docks --- .../qml/MuseScore/Playback/VideoPanel.qml | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 7b9e8b8adee5b..693c646067f36 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -36,6 +36,9 @@ Item { property NavigationSection navigationSection: null property int contentNavigationPanelOrderStart: 0 + readonly property int contentMargin: 8 + readonly property bool compactMode: width < 620 + clip: true VideoPanelModel { @@ -83,15 +86,20 @@ Item { } } - RowLayout { + GridLayout { anchors.fill: parent - anchors.margins: 8 - spacing: 12 + anchors.margins: root.contentMargin + + columns: root.compactMode ? 1 : 2 + rowSpacing: 8 + columnSpacing: 12 Rectangle { Layout.preferredWidth: 230 Layout.maximumWidth: 280 - Layout.fillHeight: true + Layout.fillWidth: root.compactMode + Layout.fillHeight: !root.compactMode + Layout.preferredHeight: root.compactMode ? 96 : -1 Layout.minimumHeight: 72 radius: 4 @@ -163,8 +171,8 @@ Item { ColumnLayout { Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumWidth: 360 + Layout.fillHeight: !root.compactMode + Layout.minimumWidth: 0 spacing: 8 RowLayout { @@ -205,7 +213,7 @@ Item { } StyledTextLabel { - Layout.preferredWidth: 92 + Layout.preferredWidth: root.compactMode ? 64 : 92 horizontalAlignment: Text.AlignRight text: videoModel.hasVideo ? Math.floor(video.position / 1000) + " / " + Math.floor(video.duration / 1000) + " s" : "" } @@ -226,6 +234,7 @@ Item { } RowLayout { + Layout.fillWidth: true spacing: 8 StyledTextLabel { @@ -233,7 +242,7 @@ Item { } FlatButton { - text: qsTrc("playback", "-100 ms") + text: root.compactMode ? qsTrc("playback", "-100") : qsTrc("playback", "-100 ms") enabled: videoModel.hasVideo navigation.panel: navigationPanel navigation.order: root.contentNavigationPanelOrderStart + 6 @@ -244,7 +253,7 @@ Item { } TextInputField { - Layout.preferredWidth: 88 + Layout.preferredWidth: root.compactMode ? 64 : 88 currentText: videoModel.offsetMs.toString() enabled: videoModel.hasVideo navigation.panel: navigationPanel @@ -263,7 +272,7 @@ Item { } FlatButton { - text: qsTrc("playback", "+100 ms") + text: root.compactMode ? qsTrc("playback", "+100") : qsTrc("playback", "+100 ms") enabled: videoModel.hasVideo navigation.panel: navigationPanel navigation.order: root.contentNavigationPanelOrderStart + 8 From d050feb8f3d4c7c7751e3c35bde76a3368e36365 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 23:04:47 -0400 Subject: [PATCH 10/28] Let video preview resize with panel --- src/playback/qml/MuseScore/Playback/VideoPanel.qml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 693c646067f36..2486c94ed03df 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -38,6 +38,7 @@ Item { readonly property int contentMargin: 8 readonly property bool compactMode: width < 620 + readonly property int controlsHeight: controlsColumn.implicitHeight clip: true @@ -95,11 +96,11 @@ Item { columnSpacing: 12 Rectangle { - Layout.preferredWidth: 230 - Layout.maximumWidth: 280 - Layout.fillWidth: root.compactMode - Layout.fillHeight: !root.compactMode - Layout.preferredHeight: root.compactMode ? 96 : -1 + Layout.preferredWidth: root.compactMode ? -1 : Math.max(320, root.width * 0.45) + Layout.maximumWidth: root.compactMode ? 16777215 : 720 + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: root.compactMode ? Math.max(140, root.height - root.controlsHeight - (root.contentMargin * 2) - 8) : -1 Layout.minimumHeight: 72 radius: 4 @@ -170,6 +171,8 @@ Item { } ColumnLayout { + id: controlsColumn + Layout.fillWidth: true Layout.fillHeight: !root.compactMode Layout.minimumWidth: 0 From 055c98f00da912f48073631bcd772895a9cfa1a6 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 23:12:21 -0400 Subject: [PATCH 11/28] Fix video solo and negative offset sync --- .../qml/MuseScore/Playback/VideoPanel.qml | 12 ++++++++++++ .../qml/MuseScore/Playback/mixerpanelmodel.cpp | 18 ++++++++++++++++++ .../qml/MuseScore/Playback/mixerpanelmodel.h | 1 + 3 files changed, 31 insertions(+) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 2486c94ed03df..f957034a095a6 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -61,6 +61,18 @@ Item { return } + if (videoModel.scorePlaybackPositionMs + videoModel.offsetMs < 0) { + if (video.position !== 0) { + video.seek(0) + } + + if (video.playbackState === MediaPlayer.PlayingState) { + video.pause() + } + + return + } + var targetPosition = targetVideoPositionMs() if (forceSeek || Math.abs(video.position - targetPosition) > syncToleranceMs) { video.seek(targetPosition) diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index 4b294cf2914ef..e3bd3e5be04f7 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -364,6 +364,7 @@ void MixerPanelModel::setupConnections() outParams.muted = attachment.muted; outParams.solo = attachment.solo; loadOutputParams(item, std::move(outParams)); + applyVideoSoloToPlayback(); }, Asyncable::Mode::SetReplace); } } @@ -595,6 +596,7 @@ MixerChannelItem* MixerPanelModel::buildVideoChannelItem() outParams.muted = attachment.muted; outParams.solo = attachment.solo; loadOutputParams(item, std::move(outParams)); + applyVideoSoloToPlayback(); connect(item, &MixerChannelItem::controlParamsChanged, this, [this](const AudioOutputParams& params) { IProjectVideoSettingsPtr settings = videoSettings(); @@ -630,6 +632,7 @@ MixerChannelItem* MixerPanelModel::buildVideoChannelItem() updated.muted = false; } settings->setAttachment(updated); + applyVideoSoloToPlayback(); }); return item; @@ -698,6 +701,21 @@ void MixerPanelModel::loadOutputParams(MixerChannelItem* item, AudioOutputParams updateOutputResourceItemCount(); } +void MixerPanelModel::applyVideoSoloToPlayback() +{ + if (!audioSettings() || !playback()) { + return; + } + + AudioOutputParams masterParams = audioSettings()->masterAudioOutputParams(); + IProjectVideoSettingsPtr settings = videoSettings(); + if (settings && settings->attachment().isValid() && settings->attachment().solo) { + masterParams.muted = true; + } + + playback()->setMasterControlParams(masterParams.control()); +} + void MixerPanelModel::updateOutputResourceItemCount() { size_t maxFxCount = 0; diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h index f1c21a3bfb649..1a51948b81866 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h @@ -110,6 +110,7 @@ class MixerPanelModel : public QAbstractListModel, public QQmlParserStatus, publ MixerChannelItem* findChannelItem(const muse::audio::TrackId& trackId) const; void loadOutputParams(MixerChannelItem* item, project::AudioOutputParams&& params); + void applyVideoSoloToPlayback(); void updateOutputResourceItemCount(); project::INotationProjectPtr currentProject() const; From 28d0ff30f397938a0b2358fc7c71e552a29d3d00 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 23:16:29 -0400 Subject: [PATCH 12/28] Keep video panel controls below preview --- src/playback/qml/MuseScore/Playback/VideoPanel.qml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index f957034a095a6..7a03a72af1317 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -103,16 +103,16 @@ Item { anchors.fill: parent anchors.margins: root.contentMargin - columns: root.compactMode ? 1 : 2 + columns: 1 rowSpacing: 8 columnSpacing: 12 Rectangle { - Layout.preferredWidth: root.compactMode ? -1 : Math.max(320, root.width * 0.45) - Layout.maximumWidth: root.compactMode ? 16777215 : 720 + Layout.preferredWidth: -1 + Layout.maximumWidth: 16777215 Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredHeight: root.compactMode ? Math.max(140, root.height - root.controlsHeight - (root.contentMargin * 2) - 8) : -1 + Layout.preferredHeight: Math.max(140, root.height - root.controlsHeight - (root.contentMargin * 2) - 8) Layout.minimumHeight: 72 radius: 4 @@ -186,7 +186,7 @@ Item { id: controlsColumn Layout.fillWidth: true - Layout.fillHeight: !root.compactMode + Layout.fillHeight: false Layout.minimumWidth: 0 spacing: 8 From 04f26ae790e566a46107cbf0d043ebc9412fd31c Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 7 May 2026 23:28:45 -0400 Subject: [PATCH 13/28] Handle video solo in playback controller --- src/playback/internal/playbackcontroller.cpp | 37 ++++++++++++++++++- src/playback/internal/playbackcontroller.h | 3 ++ .../internal/MixerAuxSendsSection.qml | 25 ++++++++----- .../MuseScore/Playback/mixerpanelmodel.cpp | 18 --------- .../qml/MuseScore/Playback/mixerpanelmodel.h | 1 - 5 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/playback/internal/playbackcontroller.cpp b/src/playback/internal/playbackcontroller.cpp index 54dd5ba15c4f4..4164b188c9d2d 100644 --- a/src/playback/internal/playbackcontroller.cpp +++ b/src/playback/internal/playbackcontroller.cpp @@ -1040,6 +1040,35 @@ mu::project::IProjectAudioSettingsPtr PlaybackController::audioSettings() const return globalContext()->currentProject()->audioSettings(); } +mu::project::IProjectVideoSettingsPtr PlaybackController::videoSettings() const +{ + if (!globalContext()->currentProject()) { + return nullptr; + } + + return globalContext()->currentProject()->videoSettings(); +} + +void PlaybackController::updateMasterControlParams() +{ + if (!globalContext()->currentProject() || !playback()) { + return; + } + + IProjectAudioSettingsPtr audioSettingsPtr = audioSettings(); + IF_ASSERT_FAILED(audioSettingsPtr) { + return; + } + + AudioOutputParams params = audioSettingsPtr->masterAudioOutputParams(); + IProjectVideoSettingsPtr videoSettingsPtr = videoSettings(); + if (videoSettingsPtr && videoSettingsPtr->attachment().isValid() && videoSettingsPtr->attachment().solo) { + params.muted = true; + } + + playback()->setMasterControlParams(params.control()); +} + void PlaybackController::resetPlayback() { if (currentPlayer()) { @@ -1352,7 +1381,7 @@ void PlaybackController::setupPlayback() const AudioOutputParams& masterOutputParams = audioSettings()->masterAudioOutputParams(); playback()->setMasterFxChainParams(masterOutputParams.fxChain); playback()->setMasterAuxSendsParams(masterOutputParams.auxSends); - playback()->setMasterControlParams(masterOutputParams.control()); + updateMasterControlParams(); subscribeOnAudioParamsChanges(); setupTracks(); @@ -1472,6 +1501,12 @@ void PlaybackController::setupTracks() updateSoloMuteStates(); }); + if (videoSettings()) { + videoSettings()->settingsChanged().onNotify(this, [this]() { + updateMasterControlParams(); + }, Asyncable::Mode::SetReplace); + } + m_isPlayAllowedChanged.notify(); } diff --git a/src/playback/internal/playbackcontroller.h b/src/playback/internal/playbackcontroller.h index bfd8184f19519..a197a6d6b17c2 100644 --- a/src/playback/internal/playbackcontroller.h +++ b/src/playback/internal/playbackcontroller.h @@ -42,6 +42,7 @@ #include "../iplaybackcontroller.h" #include "../iplaybackconfiguration.h" #include "../isoundprofilesrepository.h" +#include "project/iprojectvideosettings.h" namespace mu::playback { class OnlineSoundsController; @@ -205,6 +206,8 @@ class PlaybackController : public IPlaybackController, public muse::actions::Act void notifyActionCheckedChanged(const muse::actions::ActionCode& actionCode); project::IProjectAudioSettingsPtr audioSettings() const; + project::IProjectVideoSettingsPtr videoSettings() const; + void updateMasterControlParams(); void resetPlayback(); void setupPlayback(); diff --git a/src/playback/qml/MuseScore/Playback/internal/MixerAuxSendsSection.qml b/src/playback/qml/MuseScore/Playback/internal/MixerAuxSendsSection.qml index 7451d97e00206..08b67c356816c 100644 --- a/src/playback/qml/MuseScore/Playback/internal/MixerAuxSendsSection.qml +++ b/src/playback/qml/MuseScore/Playback/internal/MixerAuxSendsSection.qml @@ -53,23 +53,28 @@ MixerPanelSection { model: content.channelItem.auxSendItemList - delegate: AuxSendControl { - id: auxSendControl + delegate: Item { + width: root.channelItemWidth + height: auxSendControl.height required property AuxSendItem modelData required property int index - anchors.horizontalCenter: parent.horizontalCenter + AuxSendControl { + id: auxSendControl - auxSendItemModel: modelData + anchors.horizontalCenter: parent.horizontalCenter - navigationPanel: content.channelItem.panel - navigationRowStart: root.navigationRowStart + index * 2 // NOTE: 2 - because AuxSendControl has 2 controls - navigationName: content.accessibleName - accessibleName: content.accessibleName + auxSendItemModel: parent.modelData - onNavigateControlIndexChanged: function(index) { - root.navigateControlIndexChanged(index) + navigationPanel: content.channelItem.panel + navigationRowStart: root.navigationRowStart + parent.index * 2 // NOTE: 2 - because AuxSendControl has 2 controls + navigationName: content.accessibleName + accessibleName: content.accessibleName + + onNavigateControlIndexChanged: function(index) { + root.navigateControlIndexChanged(index) + } } } } diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index e3bd3e5be04f7..4b294cf2914ef 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -364,7 +364,6 @@ void MixerPanelModel::setupConnections() outParams.muted = attachment.muted; outParams.solo = attachment.solo; loadOutputParams(item, std::move(outParams)); - applyVideoSoloToPlayback(); }, Asyncable::Mode::SetReplace); } } @@ -596,7 +595,6 @@ MixerChannelItem* MixerPanelModel::buildVideoChannelItem() outParams.muted = attachment.muted; outParams.solo = attachment.solo; loadOutputParams(item, std::move(outParams)); - applyVideoSoloToPlayback(); connect(item, &MixerChannelItem::controlParamsChanged, this, [this](const AudioOutputParams& params) { IProjectVideoSettingsPtr settings = videoSettings(); @@ -632,7 +630,6 @@ MixerChannelItem* MixerPanelModel::buildVideoChannelItem() updated.muted = false; } settings->setAttachment(updated); - applyVideoSoloToPlayback(); }); return item; @@ -701,21 +698,6 @@ void MixerPanelModel::loadOutputParams(MixerChannelItem* item, AudioOutputParams updateOutputResourceItemCount(); } -void MixerPanelModel::applyVideoSoloToPlayback() -{ - if (!audioSettings() || !playback()) { - return; - } - - AudioOutputParams masterParams = audioSettings()->masterAudioOutputParams(); - IProjectVideoSettingsPtr settings = videoSettings(); - if (settings && settings->attachment().isValid() && settings->attachment().solo) { - masterParams.muted = true; - } - - playback()->setMasterControlParams(masterParams.control()); -} - void MixerPanelModel::updateOutputResourceItemCount() { size_t maxFxCount = 0; diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h index 1a51948b81866..f1c21a3bfb649 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.h @@ -110,7 +110,6 @@ class MixerPanelModel : public QAbstractListModel, public QQmlParserStatus, publ MixerChannelItem* findChannelItem(const muse::audio::TrackId& trackId) const; void loadOutputParams(MixerChannelItem* item, project::AudioOutputParams&& params); - void applyVideoSoloToPlayback(); void updateOutputResourceItemCount(); project::INotationProjectPtr currentProject() const; From 01db9b1b854843789529a9329d6013b577056c1e Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Tue, 26 May 2026 13:13:45 -0400 Subject: [PATCH 14/28] Polish video mixer solo handling --- docs/video-sync-native-proposal.md | 69 ------------------- .../MuseScore/Playback/mixerpanelmodel.cpp | 8 ++- 2 files changed, 7 insertions(+), 70 deletions(-) delete mode 100644 docs/video-sync-native-proposal.md diff --git a/docs/video-sync-native-proposal.md b/docs/video-sync-native-proposal.md deleted file mode 100644 index 766395db50ae0..0000000000000 --- a/docs/video-sync-native-proposal.md +++ /dev/null @@ -1,69 +0,0 @@ -# Native Video Sync Proposal - -## Goal - -Add a native reference-video workflow to MuseScore Studio so users can compose against picture, keep video playback aligned with the score playhead, and control video audio through MuseScore playback controls. - -## Initial Scope - -The first implementation should stay intentionally narrow: - -- attach one local video file to a score, -- store video path and offset metadata with the score, -- show a dockable video panel, -- sync play, pause, stop, and seek to MuseScore playback, -- provide video volume and mute controls. - -Mixer-level solo and full audio-graph routing should follow after the playback and score-persistence model is accepted. - -## User Workflow - -1. The user chooses an Attach Video action. -2. MuseScore stores a reference to the selected video and opens a Video panel. -3. When score playback starts, the video starts at the matching score time plus offset. -4. When playback pauses or seeks, the video follows. -5. The user can nudge offset in milliseconds while working. -6. If the video is missing, MuseScore asks the user to relink it. - -## Architecture Direction - -### Score Metadata - -Store score-level attachment data for: - -- video file path, -- offset in milliseconds, -- volume, -- mute state, -- future relink identity such as file hash or original file name. - -Prefer relative paths when the video is near the score file. Do not embed video data by default. - -### Playback Sync - -Add a video sync controller that listens to transport state and maps score playback position to video time. The controller should own drift correction rather than putting timing behavior in QML. - -### UI - -Add a dockable Video panel that uses the existing UI/dock registration patterns. The panel should be able to attach, detach, relink, play/pause through the global transport, and nudge offset. - -### Audio - -The MVP can begin with video-local mute and volume if needed. The release-quality target is a real Video source in MuseScore's audio/mixer layer so mute and solo behave like other playback channels. - -## Open Questions - -- Which existing score-project metadata layer is preferred for external media attachments? -- Which playback service should be the source of truth for elapsed score time? -- Is Qt Multimedia acceptable for the video decode/display layer on all supported platforms? -- Should video support be hidden behind an experimental flag while audio routing is incomplete? -- Should video audio participate in audio export, or should export be a later milestone? - -## Suggested Patch Sequence - -1. Add score/project metadata for an optional video attachment. -2. Add an inert internal video sync model and tests for persistence. -3. Add the Video panel UI. -4. Wire the panel to playback transport. -5. Add audio controls. -6. Add proper mixer/audio routing. diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index 4b294cf2914ef..bbece0df099e0 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -658,7 +658,13 @@ MixerChannelItem* MixerPanelModel::buildMasterChannelItem() }); connect(item, &MixerChannelItem::controlParamsChanged, this, [this](const AudioOutputParams& params) { - playback()->setMasterControlParams(params.control()); + AudioOutputParams playbackParams = params; + IProjectVideoSettingsPtr videoSettingsPtr = videoSettings(); + if (videoSettingsPtr && videoSettingsPtr->attachment().isValid() && videoSettingsPtr->attachment().solo) { + playbackParams.muted = true; + } + + playback()->setMasterControlParams(playbackParams.control()); }); connect(item, &MixerChannelItem::fxChainParamsChanged, this, [this](const AudioOutputParams& params) { From 81269336fbc659236b80dc326f91576e126677b0 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Tue, 26 May 2026 18:59:05 -0400 Subject: [PATCH 15/28] Address video sync review comments --- src/CMakeLists.txt | 4 +- src/engraving/infrastructure/mscreader.cpp | 4 ++ .../qml/MuseScore/Playback/VideoPanel.qml | 2 +- .../MuseScore/Playback/mixerpanelmodel.cpp | 3 +- src/project/internal/projectvideosettings.cpp | 20 +++++++- .../tests/projectvideosettingstest.cpp | 47 +++++++++++++++++++ 6 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index dc283534b95cd..40e91fa7d669e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,7 +18,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -find_package(Qt6 REQUIRED COMPONENTS Multimedia) +if (MUE_BUILD_PLAYBACK_MODULE) + find_package(Qt6 REQUIRED COMPONENTS Multimedia) +endif() # Modules must be listed in dependency order if (MUE_BUILD_BRAILLE_MODULE) diff --git a/src/engraving/infrastructure/mscreader.cpp b/src/engraving/infrastructure/mscreader.cpp index c65f92e64ea78..256f86e75516d 100644 --- a/src/engraving/infrastructure/mscreader.cpp +++ b/src/engraving/infrastructure/mscreader.cpp @@ -245,6 +245,10 @@ ByteArray MscReader::readAudioSettingsJsonFile(const muse::io::path_t& pathPrefi ByteArray MscReader::readVideoSettingsJsonFile() const { + if (!fileExists(u"videosettings.json")) { + return ByteArray(); + } + return fileData(u"videosettings.json"); } diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 7a03a72af1317..00df3521c3537 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -275,7 +275,7 @@ Item { navigation.order: root.contentNavigationPanelOrderStart + 7 onTextEditingFinished: function(newTextValue) { - var parsedValue = parseInt(newTextValue) + var parsedValue = parseInt(newTextValue, 10) if (!isNaN(parsedValue)) { videoModel.offsetMs = parsedValue } diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index bbece0df099e0..a61e3d416b3d1 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -51,7 +51,8 @@ static volume_db_t videoVolumeToDb(float volume) static float videoVolumeFromDb(volume_db_t volume) { - return std::clamp(muse::db_to_linear(volume).raw(), 0.f, 1.f); + float linear = std::clamp(muse::db_to_linear(volume).raw(), 0.f, 1.f); + return linear <= 0.001f ? 0.f : linear; } MixerPanelModel::MixerPanelModel(QObject* parent) diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index 6b919cad05060..84aaf4dd6f30a 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -24,6 +24,8 @@ #include #include +#include +#include #include "types/bytearray.h" @@ -65,8 +67,22 @@ muse::Ret ProjectVideoSettings::read(const engraving::MscReader& reader) return make_ret(Ret::Code::Ok); } - QJsonObject rootObj = QJsonDocument::fromJson(json.toQByteArrayNoCopy()).object(); - m_attachment = attachmentFromJson(rootObj.value("attachment").toObject()); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(json.toQByteArrayNoCopy(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + makeDefault(); + return make_ret(Ret::Code::Ok); + } + + QJsonObject rootObj = document.object(); + const int version = rootObj.value("version").toInt(0); + const QJsonValue attachmentValue = rootObj.value("attachment"); + if (version != VIDEO_SETTINGS_VERSION || !attachmentValue.isObject()) { + makeDefault(); + return make_ret(Ret::Code::Ok); + } + + m_attachment = attachmentFromJson(attachmentValue.toObject()); return make_ret(Ret::Code::Ok); } diff --git a/src/project/tests/projectvideosettingstest.cpp b/src/project/tests/projectvideosettingstest.cpp index f332e9ccb263b..ca937530a0544 100644 --- a/src/project/tests/projectvideosettingstest.cpp +++ b/src/project/tests/projectvideosettingstest.cpp @@ -32,6 +32,32 @@ using namespace muse; using namespace mu::engraving; using namespace mu::project; +static VideoAttachmentSettings readAttachmentFromJson(const QString& json) +{ + QTemporaryDir dir; + EXPECT_TRUE(dir.isValid()); + + MscWriter::Params writerParams; + writerParams.filePath = dir.path(); + writerParams.mode = MscIoMode::Dir; + + MscWriter writer(writerParams); + EXPECT_TRUE(writer.open()); + writer.writeVideoSettingsJsonFile(ByteArray::fromQByteArray(json.toUtf8())); + writer.close(); + + MscReader::Params readerParams; + readerParams.filePath = dir.path(); + readerParams.mode = MscIoMode::Dir; + + MscReader reader(readerParams); + EXPECT_TRUE(reader.open()); + + ProjectVideoSettings settings; + EXPECT_TRUE(settings.read(reader)); + return settings.attachment(); +} + TEST(ProjectVideoSettingsTests, MissingSettingsReadAsDefault) { QTemporaryDir dir; @@ -50,6 +76,27 @@ TEST(ProjectVideoSettingsTests, MissingSettingsReadAsDefault) EXPECT_FALSE(settings.attachment().isValid()); } +TEST(ProjectVideoSettingsTests, InvalidSettingsReadAsDefault) +{ + VideoAttachmentSettings attachment = readAttachmentFromJson("{"); + + EXPECT_FALSE(attachment.isValid()); +} + +TEST(ProjectVideoSettingsTests, UnknownVersionReadsAsDefault) +{ + VideoAttachmentSettings attachment = readAttachmentFromJson(R"({"version":999,"attachment":{"path":"media/reference-picture.mp4"}})"); + + EXPECT_FALSE(attachment.isValid()); +} + +TEST(ProjectVideoSettingsTests, MissingAttachmentReadsAsDefault) +{ + VideoAttachmentSettings attachment = readAttachmentFromJson(R"({"version":1})"); + + EXPECT_FALSE(attachment.isValid()); +} + TEST(ProjectVideoSettingsTests, WriteAndReadAttachment) { QTemporaryDir dir; From 35a3e2bd75ed48ba888ef74563eff3a3dadd0179 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:01:51 -0400 Subject: [PATCH 16/28] Add video timecode and hit point timeline support --- .../MuseScore/NotationScene/timelineview.cpp | 7 + src/notationscene/widgets/timeline.cpp | 105 +++++++++++ src/notationscene/widgets/timeline.h | 5 + .../qml/MuseScore/Playback/VideoPanel.qml | 167 +++++++++++++++++- .../MuseScore/Playback/videopanelmodel.cpp | 139 +++++++++++++++ .../qml/MuseScore/Playback/videopanelmodel.h | 17 ++ src/project/internal/projectvideosettings.cpp | 48 +++++ src/project/internal/projectvideosettings.h | 2 + src/project/iprojectvideosettings.h | 35 +++- .../tests/projectvideosettingstest.cpp | 14 ++ 10 files changed, 537 insertions(+), 2 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp b/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp index ac709ba0e4ba5..09f954eb1be97 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp @@ -30,6 +30,7 @@ #include #include "log.h" +#include "project/inotationproject.h" namespace mu::notation { class TimelineAdapter : public QSplitter, public muse::uicomponents::IDisplayableWidget @@ -168,6 +169,12 @@ void TimelineView::componentComplete() notation->interaction()->selectionChanged().onNotify(this, [=] { updateView(); }, Asyncable::Mode::SetReplace /* FIXME */); + + if (notation->project() && notation->project()->videoSettings()) { + notation->project()->videoSettings()->settingsChanged().onNotify(this, [=] { + updateView(); + }, Asyncable::Mode::SetReplace); + } }; globalContext()->currentNotationChanged().onNotify(this, [initTimeline]() { diff --git a/src/notationscene/widgets/timeline.cpp b/src/notationscene/widgets/timeline.cpp index 791472acab94f..002b3c99ffc4f 100644 --- a/src/notationscene/widgets/timeline.cpp +++ b/src/notationscene/widgets/timeline.cpp @@ -29,6 +29,9 @@ #include #include +#include +#include + #include "translation.h" #include "engraving/dom/barline.h" @@ -48,6 +51,7 @@ #include "engraving/dom/tempotext.h" #include "engraving/dom/timesig.h" #include "engraving/types/typesconv.h" +#include "project/inotationproject.h" #include "log.h" @@ -766,6 +770,8 @@ Timeline::Timeline(QSplitter* splitter, const muse::modularity::ContextPtr& iocC _metas.push_back({ muse::qtrc("notation/timeline", "Tempo"), &Timeline::tempoMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Time signature"), &Timeline::timeMeta, true }); + _metas.push_back({ muse::qtrc("notation/timeline", "Timecode"), &Timeline::timecodeMeta, true }); + _metas.push_back({ muse::qtrc("notation/timeline", "Hit points"), &Timeline::hitPointMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Rehearsal mark"), &Timeline::rehearsalMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Key signature"), &Timeline::keyMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Barlines"), &Timeline::barlineMeta, true }); @@ -1474,6 +1480,78 @@ void Timeline::jumpMarkerMeta(Segment* seg, int* stagger, int pos) std::get<3>(_repeatInfo) = nullptr; } +//--------------------------------------------------------- +// Timeline::hitPointMeta +//--------------------------------------------------------- + +void Timeline::hitPointMeta(Segment* seg, int* stagger, int pos) +{ + if (!seg || seg != seg->measure()->first()) { + return; + } + + project::IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings || !settings->attachment().isValid()) { + return; + } + + const project::VideoAttachmentSettings& attachment = settings->attachment(); + if (attachment.hitPoints.empty()) { + return; + } + + const int row = getMetaRow(muse::qtrc("notation/timeline", "Hit points")); + const int currentMeasureIndex = pos / _gridWidth; + + for (const project::VideoHitPointSettings& hitPoint : attachment.hitPoints) { + const double scoreTimeSeconds = std::max(0.0, static_cast(hitPoint.timeMs - attachment.offsetMs) / 1000.0); + const int tick = std::max(0, score()->utime2utick(scoreTimeSeconds)); + const Measure* measure = score()->tick2measure(Fraction::fromTicks(tick)); + if (!measure || measure->measureIndex() != currentMeasureIndex) { + continue; + } + + const int x = pos + (*stagger) * _spacing; + const QString label = hitPoint.label.empty() ? muse::qtrc("notation/timeline", "Hit") : hitPoint.label.toQString(); + const QString tooltip = muse::qtrc("notation/timeline", "Video hit point at %1").arg(formatVideoTimecode(hitPoint.timeMs)); + if (addMetaValue(x, pos, label, row, ElementType::INVALID, nullptr, seg, seg->measure(), tooltip)) { + (*stagger)++; + _globalZValue++; + } + } +} + +//--------------------------------------------------------- +// Timeline::timecodeMeta +//--------------------------------------------------------- + +void Timeline::timecodeMeta(Segment* seg, int* stagger, int pos) +{ + if (!seg || seg != seg->measure()->first()) { + return; + } + + project::IProjectVideoSettingsPtr settings = videoSettings(); + if (!settings || !settings->attachment().isValid()) { + return; + } + + const project::VideoAttachmentSettings& attachment = settings->attachment(); + if (attachment.timecodeDisplayMode == project::VideoTimecodeDisplayMode::Off) { + return; + } + + const int videoPositionMs = std::max(0, static_cast(std::lround(score()->utick2utime(seg->measure()->tick().ticks()) * 1000.0)) + + attachment.offsetMs); + const int row = getMetaRow(muse::qtrc("notation/timeline", "Timecode")); + const int x = pos + (*stagger) * _spacing; + + if (addMetaValue(x, pos, formatVideoTimecode(videoPositionMs), row, ElementType::INVALID, nullptr, seg, seg->measure())) { + (*stagger)++; + _globalZValue++; + } +} + //--------------------------------------------------------- // Timeline::measureMeta //--------------------------------------------------------- @@ -2546,6 +2624,8 @@ void Timeline::changeEvent(QEvent* event) _metas.clear(); _metas.push_back({ muse::qtrc("notation/timeline", "Tempo"), &Timeline::tempoMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Time signature"), &Timeline::timeMeta, true }); + _metas.push_back({ muse::qtrc("notation/timeline", "Timecode"), &Timeline::timecodeMeta, true }); + _metas.push_back({ muse::qtrc("notation/timeline", "Hit points"), &Timeline::hitPointMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Rehearsal mark"), &Timeline::rehearsalMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Key signature"), &Timeline::keyMeta, true }); _metas.push_back({ muse::qtrc("notation/timeline", "Barlines"), &Timeline::barlineMeta, true }); @@ -3252,6 +3332,31 @@ Score* Timeline::score() const return m_notation ? m_notation->elements()->msScore() : nullptr; } +project::IProjectVideoSettingsPtr Timeline::videoSettings() const +{ + return m_notation && m_notation->project() ? m_notation->project()->videoSettings() : nullptr; +} + +QString Timeline::formatVideoTimecode(int videoPositionMs) const +{ + const project::IProjectVideoSettingsPtr settings = videoSettings(); + const double framesPerSecond = settings && settings->attachment().isValid() ? settings->attachment().frameRate : 24.0; + const int roundedFrameRate = std::max(1, static_cast(std::lround(std::clamp(framesPerSecond, 1.0, 240.0)))); + const qint64 totalFrames = static_cast(std::floor((std::max(0, videoPositionMs) / 1000.0) * framesPerSecond + 0.5)); + + const qint64 frames = totalFrames % roundedFrameRate; + const qint64 totalSeconds = totalFrames / roundedFrameRate; + const qint64 seconds = totalSeconds % 60; + const qint64 minutes = (totalSeconds / 60) % 60; + const qint64 hours = totalSeconds / 3600; + + return QString("%1:%2:%3:%4") + .arg(hours, 2, 10, QLatin1Char('0')) + .arg(minutes, 2, 10, QLatin1Char('0')) + .arg(seconds, 2, 10, QLatin1Char('0')) + .arg(frames, 2, 10, QLatin1Char('0')); +} + TRowLabels* Timeline::labelsColumn() const { return _rowNames; diff --git a/src/notationscene/widgets/timeline.h b/src/notationscene/widgets/timeline.h index 43186907052ef..98fa9a7f27b4d 100644 --- a/src/notationscene/widgets/timeline.h +++ b/src/notationscene/widgets/timeline.h @@ -28,6 +28,7 @@ #include "modularity/ioc.h" #include "ui/iuiconfiguration.h" #include "notation/inotation.h" +#include "project/iprojectvideosettings.h" #include "async/asyncable.h" #include "actions/iactionsdispatcher.h" #include "playback/iplaybackcontroller.h" @@ -190,6 +191,8 @@ class Timeline : public QGraphicsView, public muse::Contextable, public muse::as void keyMeta(engraving::Segment* seg, int* stagger, int pos); void barlineMeta(engraving::Segment* seg, int* stagger, int pos); void jumpMarkerMeta(engraving::Segment* seg, int* stagger, int pos); + void hitPointMeta(engraving::Segment* seg, int* stagger, int pos); + void timecodeMeta(engraving::Segment* seg, int* stagger, int pos); bool addMetaValue(int x, int pos, QString metaText, int row, engraving::ElementType elementType, engraving::EngravingItem* element, engraving::Segment* seg, engraving::Measure* measure, QString tooltip = ""); @@ -227,6 +230,8 @@ class Timeline : public QGraphicsView, public muse::Contextable, public muse::as INotationInteractionPtr interaction() const; engraving::Score* score() const; + project::IProjectVideoSettingsPtr videoSettings() const; + QString formatVideoTimecode(int videoPositionMs) const; private slots: void handleScroll(int value); diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 00df3521c3537..d7ee42d798482 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -180,6 +180,43 @@ Item { video.play() } } + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 28 + visible: videoModel.hasVideo && video.duration > 0 && videoModel.hitPoints.length > 0 + + Rectangle { + anchors.fill: parent + color: "#99000000" + } + + Repeater { + model: videoModel.hitPoints + + Rectangle { + required property var modelData + + x: Math.max(0, Math.min(parent.width - width, (modelData.timeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) + y: 3 + width: 2 + height: parent.height - 6 + color: "#" + ("000000" + modelData.color.toString(16)).slice(-6) + + StyledTextLabel { + anchors.left: parent.right + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + text: parent.modelData.label + maximumLineCount: 1 + font.pixelSize: 10 + color: "white" + } + } + } + } } ColumnLayout { @@ -298,6 +335,134 @@ Item { } } + RowLayout { + Layout.fillWidth: true + spacing: 8 + + StyledTextLabel { + text: qsTrc("playback", "Timecode") + } + + FlatButton { + text: qsTrc("playback", "Off") + enabled: videoModel.hasVideo + accentButton: videoModel.timecodeDisplayMode === 0 + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 9 + + onClicked: { + videoModel.timecodeDisplayMode = 0 + } + } + + FlatButton { + text: qsTrc("playback", "Above") + enabled: videoModel.hasVideo + accentButton: videoModel.timecodeDisplayMode === 1 + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 10 + + onClicked: { + videoModel.timecodeDisplayMode = 1 + } + } + + FlatButton { + text: qsTrc("playback", "Below") + enabled: videoModel.hasVideo + accentButton: videoModel.timecodeDisplayMode === 2 + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 11 + + onClicked: { + videoModel.timecodeDisplayMode = 2 + } + } + + StyledTextLabel { + text: qsTrc("playback", "fps") + } + + TextInputField { + Layout.preferredWidth: 56 + currentText: videoModel.frameRate.toString() + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 12 + + onTextEditingFinished: function(newTextValue) { + var parsedValue = parseFloat(newTextValue) + if (!isNaN(parsedValue)) { + videoModel.frameRate = parsedValue + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + visible: videoModel.hasVideo + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + StyledTextLabel { + Layout.fillWidth: true + text: videoModel.hasVideo ? videoModel.formatTimecode(video.position) : "" + } + + FlatButton { + text: qsTrc("playback", "Add hit point") + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 13 + + onClicked: { + videoModel.addHitPoint(video.position) + } + } + } + + Repeater { + model: videoModel.hitPoints + + RowLayout { + required property var modelData + required property int index + + Layout.fillWidth: true + spacing: 8 + + StyledTextLabel { + Layout.preferredWidth: 92 + text: parent.modelData.timecode + maximumLineCount: 1 + } + + StyledTextLabel { + Layout.fillWidth: true + text: parent.modelData.label + " " + parent.modelData.musicalPosition + maximumLineCount: 1 + } + + FlatButton { + Layout.preferredWidth: 32 + Layout.preferredHeight: 28 + icon: IconCode.DELETE_TANK + buttonType: FlatButton.IconOnly + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 14 + parent.index + + onClicked: { + videoModel.removeHitPoint(parent.index) + } + } + } + } + } + RowLayout { Layout.fillWidth: true @@ -309,7 +474,7 @@ Item { text: qsTrc("playback", "Clear") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 9 + navigation.order: root.contentNavigationPanelOrderStart + 30 onClicked: { video.stop() diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index 9286e64b4f934..6cf0746500da0 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -25,6 +25,10 @@ #include #include +#include "engraving/dom/measure.h" +#include "engraving/dom/score.h" +#include "engraving/types/constants.h" + using namespace mu::playback; using namespace mu::project; @@ -61,6 +65,58 @@ void VideoPanelModel::nudgeOffset(int deltaMs) setOffsetMs(offsetMs() + deltaMs); } +void VideoPanelModel::addHitPoint(int videoPositionMs) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid()) { + return; + } + + videoPositionMs = std::max(0, videoPositionMs); + + VideoHitPointSettings hitPoint; + hitPoint.timeMs = videoPositionMs; + hitPoint.label = muse::String(u"Hit %1").arg(static_cast(updated.hitPoints.size()) + 1); + updated.hitPoints.push_back(hitPoint); + + std::sort(updated.hitPoints.begin(), updated.hitPoints.end(), [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { + return a.timeMs < b.timeMs; + }); + + updateAttachment(updated); +} + +void VideoPanelModel::removeHitPoint(int index) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || index < 0 || index >= static_cast(updated.hitPoints.size())) { + return; + } + + updated.hitPoints.erase(updated.hitPoints.begin() + index); + updateAttachment(updated); +} + +QString VideoPanelModel::formatTimecode(int videoPositionMs) const +{ + videoPositionMs = std::max(0, videoPositionMs); + const double framesPerSecond = std::clamp(frameRate(), 1.0, 240.0); + const int roundedFrameRate = std::max(1, static_cast(std::lround(framesPerSecond))); + const qint64 totalFrames = static_cast(std::floor((videoPositionMs / 1000.0) * framesPerSecond + 0.5)); + + const qint64 frames = totalFrames % roundedFrameRate; + const qint64 totalSeconds = totalFrames / roundedFrameRate; + const qint64 seconds = totalSeconds % 60; + const qint64 minutes = (totalSeconds / 60) % 60; + const qint64 hours = totalSeconds / 3600; + + return QString("%1:%2:%3:%4") + .arg(hours, 2, 10, QLatin1Char('0')) + .arg(minutes, 2, 10, QLatin1Char('0')) + .arg(seconds, 2, 10, QLatin1Char('0')) + .arg(frames, 2, 10, QLatin1Char('0')); +} + bool VideoPanelModel::hasVideo() const { return attachment().isValid(); @@ -189,6 +245,58 @@ void VideoPanelModel::setSolo(bool solo) updateAttachment(updated); } +double VideoPanelModel::frameRate() const +{ + return std::clamp(attachment().frameRate, 1.0, 240.0); +} + +void VideoPanelModel::setFrameRate(double frameRate) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid()) { + return; + } + + frameRate = std::clamp(frameRate, 1.0, 240.0); + if (std::abs(updated.frameRate - frameRate) < 0.001) { + return; + } + + updated.frameRate = frameRate; + updateAttachment(updated); +} + +int VideoPanelModel::timecodeDisplayMode() const +{ + return static_cast(attachment().timecodeDisplayMode); +} + +void VideoPanelModel::setTimecodeDisplayMode(int mode) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid()) { + return; + } + + mode = std::clamp(mode, static_cast(VideoTimecodeDisplayMode::Off), static_cast(VideoTimecodeDisplayMode::BelowBars)); + const VideoTimecodeDisplayMode displayMode = static_cast(mode); + if (updated.timecodeDisplayMode == displayMode) { + return; + } + + updated.timecodeDisplayMode = displayMode; + updateAttachment(updated); +} + +QVariantList VideoPanelModel::hitPoints() const +{ + QVariantList result; + for (const VideoHitPointSettings& hitPoint : attachment().hitPoints) { + result.push_back(hitPointToMap(hitPoint)); + } + return result; +} + bool VideoPanelModel::scorePlaying() const { return playbackController()->isPlaying(); @@ -221,6 +329,37 @@ void VideoPanelModel::updateAttachment(const VideoAttachmentSettings& attachment settings->setAttachment(attachment); } +QVariantMap VideoPanelModel::hitPointToMap(const VideoHitPointSettings& hitPoint) const +{ + QVariantMap result; + result["label"] = hitPoint.label.toQString(); + result["timeMs"] = hitPoint.timeMs; + result["timecode"] = formatTimecode(hitPoint.timeMs); + result["musicalPosition"] = musicalPositionText(hitPoint.timeMs); + result["color"] = hitPoint.color; + return result; +} + +QString VideoPanelModel::musicalPositionText(int videoPositionMs) const +{ + INotationProjectPtr project = context()->currentProject(); + if (!project || !project->masterNotation() || !project->masterNotation()->masterScore()) { + return QString(); + } + + engraving::Score* score = project->masterNotation()->masterScore(); + const double scoreTimeSeconds = std::max(0.0, static_cast(videoPositionMs - offsetMs()) / 1000.0); + const int tick = std::max(0, score->utime2utick(scoreTimeSeconds)); + const engraving::Measure* measure = score->tick2measure(engraving::Fraction::fromTicks(tick)); + if (!measure) { + return QString(); + } + + const int beatTicks = engraving::Constants::DIVISION; + const int beat = std::max(1, ((tick - measure->tick().ticks()) / beatTicks) + 1); + return QString("%1.%2").arg(measure->measureNumber() + 1).arg(beat); +} + void VideoPanelModel::listenCurrentProject() { IProjectVideoSettingsPtr settings = videoSettings(); diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 864f8e1f2bb2a..2ba4d0fad5a5b 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -24,6 +24,7 @@ #include #include +#include #include #include "async/asyncable.h" @@ -45,6 +46,9 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_PROPERTY(int balance READ balance WRITE setBalance NOTIFY videoSettingsChanged) Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY videoSettingsChanged) Q_PROPERTY(bool solo READ solo WRITE setSolo NOTIFY videoSettingsChanged) + Q_PROPERTY(double frameRate READ frameRate WRITE setFrameRate NOTIFY videoSettingsChanged) + Q_PROPERTY(int timecodeDisplayMode READ timecodeDisplayMode WRITE setTimecodeDisplayMode NOTIFY videoSettingsChanged) + Q_PROPERTY(QVariantList hitPoints READ hitPoints NOTIFY videoSettingsChanged) Q_PROPERTY(bool scorePlaying READ scorePlaying NOTIFY playbackSyncChanged) Q_PROPERTY(int scorePlaybackPositionMs READ scorePlaybackPositionMs NOTIFY playbackSyncChanged) @@ -59,6 +63,9 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_INVOKABLE void load(); Q_INVOKABLE void clearVideo(); Q_INVOKABLE void nudgeOffset(int deltaMs); + Q_INVOKABLE void addHitPoint(int videoPositionMs); + Q_INVOKABLE void removeHitPoint(int index); + Q_INVOKABLE QString formatTimecode(int videoPositionMs) const; bool hasVideo() const; QString videoPath() const; @@ -80,6 +87,14 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a bool solo() const; void setSolo(bool solo); + double frameRate() const; + void setFrameRate(double frameRate); + + int timecodeDisplayMode() const; + void setTimecodeDisplayMode(int mode); + + QVariantList hitPoints() const; + bool scorePlaying() const; int scorePlaybackPositionMs() const; @@ -91,6 +106,8 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a project::IProjectVideoSettingsPtr videoSettings() const; project::VideoAttachmentSettings attachment() const; void updateAttachment(const project::VideoAttachmentSettings& attachment); + QVariantMap hitPointToMap(const project::VideoHitPointSettings& hitPoint) const; + QString musicalPositionText(int videoPositionMs) const; void listenCurrentProject(); void listenPlaybackController(); diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index 84aaf4dd6f30a..180bc915dde97 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -22,6 +22,9 @@ #include "projectvideosettings.h" +#include + +#include #include #include #include @@ -108,6 +111,24 @@ void ProjectVideoSettings::makeDefault() m_attachment = VideoAttachmentSettings(); } +VideoHitPointSettings ProjectVideoSettings::hitPointFromJson(const QJsonObject& object) const +{ + VideoHitPointSettings result; + result.label = object.value("label").toString(); + result.timeMs = std::max(0, object.value("timeMs").toInt()); + result.color = object.value("color").toInt(0x3B94E5); + return result; +} + +QJsonObject ProjectVideoSettings::hitPointToJson(const VideoHitPointSettings& hitPoint) const +{ + QJsonObject object; + object["label"] = hitPoint.label.toQString(); + object["timeMs"] = hitPoint.timeMs; + object["color"] = hitPoint.color; + return object; +} + VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObject& object) const { VideoAttachmentSettings result; @@ -117,6 +138,24 @@ VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObje result.balance = static_cast(object.value("balance").toDouble(0.0)); result.muted = object.value("muted").toBool(false); result.solo = object.value("solo").toBool(false); + result.frameRate = std::clamp(object.value("frameRate").toDouble(24.0), 1.0, 240.0); + result.timecodeDisplayMode = static_cast( + std::clamp(object.value("timecodeDisplayMode").toInt(static_cast(VideoTimecodeDisplayMode::Off)), + static_cast(VideoTimecodeDisplayMode::Off), + static_cast(VideoTimecodeDisplayMode::BelowBars))); + + const QJsonArray hitPoints = object.value("hitPoints").toArray(); + result.hitPoints.reserve(static_cast(hitPoints.size())); + for (const QJsonValue& hitPointValue : hitPoints) { + if (hitPointValue.isObject()) { + result.hitPoints.push_back(hitPointFromJson(hitPointValue.toObject())); + } + } + + std::sort(result.hitPoints.begin(), result.hitPoints.end(), [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { + return a.timeMs < b.timeMs; + }); + return result; } @@ -129,5 +168,14 @@ QJsonObject ProjectVideoSettings::attachmentToJson(const VideoAttachmentSettings object["balance"] = attachment.balance; object["muted"] = attachment.muted; object["solo"] = attachment.solo; + object["frameRate"] = attachment.frameRate; + object["timecodeDisplayMode"] = static_cast(attachment.timecodeDisplayMode); + + QJsonArray hitPoints; + for (const VideoHitPointSettings& hitPoint : attachment.hitPoints) { + hitPoints.append(hitPointToJson(hitPoint)); + } + object["hitPoints"] = hitPoints; + return object; } diff --git a/src/project/internal/projectvideosettings.h b/src/project/internal/projectvideosettings.h index 8aa1c39d1185e..a775aefc5bf0c 100644 --- a/src/project/internal/projectvideosettings.h +++ b/src/project/internal/projectvideosettings.h @@ -46,6 +46,8 @@ class ProjectVideoSettings : public IProjectVideoSettings void makeDefault(); private: + VideoHitPointSettings hitPointFromJson(const QJsonObject& object) const; + QJsonObject hitPointToJson(const VideoHitPointSettings& hitPoint) const; VideoAttachmentSettings attachmentFromJson(const QJsonObject& object) const; QJsonObject attachmentToJson(const VideoAttachmentSettings& attachment) const; diff --git a/src/project/iprojectvideosettings.h b/src/project/iprojectvideosettings.h index 6b66174a9243d..097dc36aefe19 100644 --- a/src/project/iprojectvideosettings.h +++ b/src/project/iprojectvideosettings.h @@ -23,11 +23,38 @@ #pragma once #include +#include #include "async/notification.h" #include "global/io/path.h" +#include "global/types/string.h" namespace mu::project { +enum class VideoTimecodeDisplayMode { + Off = 0, + AboveBars, + BelowBars +}; + +struct VideoHitPointSettings +{ + muse::String label; + int timeMs = 0; + int color = 0x3B94E5; + + bool operator==(const VideoHitPointSettings& other) const + { + return label == other.label + && timeMs == other.timeMs + && color == other.color; + } + + bool operator!=(const VideoHitPointSettings& other) const + { + return !(*this == other); + } +}; + struct VideoAttachmentSettings { muse::io::path_t path; @@ -36,6 +63,9 @@ struct VideoAttachmentSettings float balance = 0.f; bool muted = false; bool solo = false; + double frameRate = 24.0; + VideoTimecodeDisplayMode timecodeDisplayMode = VideoTimecodeDisplayMode::Off; + std::vector hitPoints; bool isValid() const { @@ -49,7 +79,10 @@ struct VideoAttachmentSettings && volume == other.volume && balance == other.balance && muted == other.muted - && solo == other.solo; + && solo == other.solo + && frameRate == other.frameRate + && timecodeDisplayMode == other.timecodeDisplayMode + && hitPoints == other.hitPoints; } bool operator!=(const VideoAttachmentSettings& other) const diff --git a/src/project/tests/projectvideosettingstest.cpp b/src/project/tests/projectvideosettingstest.cpp index ca937530a0544..336b259389457 100644 --- a/src/project/tests/projectvideosettingstest.cpp +++ b/src/project/tests/projectvideosettingstest.cpp @@ -117,6 +117,20 @@ TEST(ProjectVideoSettingsTests, WriteAndReadAttachment) attachment.balance = -0.25f; attachment.muted = true; attachment.solo = true; + attachment.frameRate = 29.97; + attachment.timecodeDisplayMode = VideoTimecodeDisplayMode::AboveBars; + + VideoHitPointSettings firstHitPoint; + firstHitPoint.label = u"Door slam"; + firstHitPoint.timeMs = 3200; + firstHitPoint.color = 0xD94A4A; + attachment.hitPoints.push_back(firstHitPoint); + + VideoHitPointSettings secondHitPoint; + secondHitPoint.label = u"Cut"; + secondHitPoint.timeMs = 9600; + secondHitPoint.color = 0x3B94E5; + attachment.hitPoints.push_back(secondHitPoint); source.setAttachment(attachment); EXPECT_TRUE(source.write(writer)); From 81481e853e87b8745e071be602be53768d0fd138 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:26:21 -0400 Subject: [PATCH 17/28] Address video timeline review comments --- src/notationscene/widgets/timeline.cpp | 35 +++++++++++-------- .../MuseScore/Playback/videopanelmodel.cpp | 5 +-- src/project/internal/projectvideosettings.cpp | 19 +++++++--- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/notationscene/widgets/timeline.cpp b/src/notationscene/widgets/timeline.cpp index 002b3c99ffc4f..22a37f71756c2 100644 --- a/src/notationscene/widgets/timeline.cpp +++ b/src/notationscene/widgets/timeline.cpp @@ -1490,12 +1490,12 @@ void Timeline::hitPointMeta(Segment* seg, int* stagger, int pos) return; } - project::IProjectVideoSettingsPtr settings = videoSettings(); + mu::project::IProjectVideoSettingsPtr settings = videoSettings(); if (!settings || !settings->attachment().isValid()) { return; } - const project::VideoAttachmentSettings& attachment = settings->attachment(); + const mu::project::VideoAttachmentSettings& attachment = settings->attachment(); if (attachment.hitPoints.empty()) { return; } @@ -1503,8 +1503,13 @@ void Timeline::hitPointMeta(Segment* seg, int* stagger, int pos) const int row = getMetaRow(muse::qtrc("notation/timeline", "Hit points")); const int currentMeasureIndex = pos / _gridWidth; - for (const project::VideoHitPointSettings& hitPoint : attachment.hitPoints) { - const double scoreTimeSeconds = std::max(0.0, static_cast(hitPoint.timeMs - attachment.offsetMs) / 1000.0); + for (const mu::project::VideoHitPointSettings& hitPoint : attachment.hitPoints) { + const int scoreRelativeMs = hitPoint.timeMs - attachment.offsetMs; + if (scoreRelativeMs < 0) { + continue; + } + + const double scoreTimeSeconds = static_cast(scoreRelativeMs) / 1000.0; const int tick = std::max(0, score()->utime2utick(scoreTimeSeconds)); const Measure* measure = score()->tick2measure(Fraction::fromTicks(tick)); if (!measure || measure->measureIndex() != currentMeasureIndex) { @@ -1514,7 +1519,7 @@ void Timeline::hitPointMeta(Segment* seg, int* stagger, int pos) const int x = pos + (*stagger) * _spacing; const QString label = hitPoint.label.empty() ? muse::qtrc("notation/timeline", "Hit") : hitPoint.label.toQString(); const QString tooltip = muse::qtrc("notation/timeline", "Video hit point at %1").arg(formatVideoTimecode(hitPoint.timeMs)); - if (addMetaValue(x, pos, label, row, ElementType::INVALID, nullptr, seg, seg->measure(), tooltip)) { + if (addMetaValue(x, pos, label, row, ElementType::INVALID, nullptr, nullptr, seg->measure(), tooltip)) { (*stagger)++; _globalZValue++; } @@ -1531,22 +1536,24 @@ void Timeline::timecodeMeta(Segment* seg, int* stagger, int pos) return; } - project::IProjectVideoSettingsPtr settings = videoSettings(); + mu::project::IProjectVideoSettingsPtr settings = videoSettings(); if (!settings || !settings->attachment().isValid()) { return; } - const project::VideoAttachmentSettings& attachment = settings->attachment(); - if (attachment.timecodeDisplayMode == project::VideoTimecodeDisplayMode::Off) { + const mu::project::VideoAttachmentSettings& attachment = settings->attachment(); + if (attachment.timecodeDisplayMode == mu::project::VideoTimecodeDisplayMode::Off) { return; } - const int videoPositionMs = std::max(0, static_cast(std::lround(score()->utick2utime(seg->measure()->tick().ticks()) * 1000.0)) - + attachment.offsetMs); + const int measureTick = seg->measure()->tick().ticks(); + const double measureTimeSeconds = score()->utick2utime(measureTick); + const int measureTimeMs = static_cast(std::lround(measureTimeSeconds * 1000.0)); + const int videoPositionMs = std::max(0, measureTimeMs + attachment.offsetMs); const int row = getMetaRow(muse::qtrc("notation/timeline", "Timecode")); const int x = pos + (*stagger) * _spacing; - if (addMetaValue(x, pos, formatVideoTimecode(videoPositionMs), row, ElementType::INVALID, nullptr, seg, seg->measure())) { + if (addMetaValue(x, pos, formatVideoTimecode(videoPositionMs), row, ElementType::INVALID, nullptr, nullptr, seg->measure())) { (*stagger)++; _globalZValue++; } @@ -3332,17 +3339,17 @@ Score* Timeline::score() const return m_notation ? m_notation->elements()->msScore() : nullptr; } -project::IProjectVideoSettingsPtr Timeline::videoSettings() const +mu::project::IProjectVideoSettingsPtr Timeline::videoSettings() const { return m_notation && m_notation->project() ? m_notation->project()->videoSettings() : nullptr; } QString Timeline::formatVideoTimecode(int videoPositionMs) const { - const project::IProjectVideoSettingsPtr settings = videoSettings(); + const mu::project::IProjectVideoSettingsPtr settings = videoSettings(); const double framesPerSecond = settings && settings->attachment().isValid() ? settings->attachment().frameRate : 24.0; const int roundedFrameRate = std::max(1, static_cast(std::lround(std::clamp(framesPerSecond, 1.0, 240.0)))); - const qint64 totalFrames = static_cast(std::floor((std::max(0, videoPositionMs) / 1000.0) * framesPerSecond + 0.5)); + const qint64 totalFrames = static_cast(std::floor((std::max(0, videoPositionMs) / 1000.0) * roundedFrameRate + 0.5)); const qint64 frames = totalFrames % roundedFrameRate; const qint64 totalSeconds = totalFrames / roundedFrameRate; diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index 6cf0746500da0..a220bf92fbe3d 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -25,6 +25,7 @@ #include #include +#include "engraving/dom/masterscore.h" #include "engraving/dom/measure.h" #include "engraving/dom/score.h" #include "engraving/types/constants.h" @@ -102,7 +103,7 @@ QString VideoPanelModel::formatTimecode(int videoPositionMs) const videoPositionMs = std::max(0, videoPositionMs); const double framesPerSecond = std::clamp(frameRate(), 1.0, 240.0); const int roundedFrameRate = std::max(1, static_cast(std::lround(framesPerSecond))); - const qint64 totalFrames = static_cast(std::floor((videoPositionMs / 1000.0) * framesPerSecond + 0.5)); + const qint64 totalFrames = static_cast(std::floor((videoPositionMs / 1000.0) * roundedFrameRate + 0.5)); const qint64 frames = totalFrames % roundedFrameRate; const qint64 totalSeconds = totalFrames / roundedFrameRate; @@ -347,7 +348,7 @@ QString VideoPanelModel::musicalPositionText(int videoPositionMs) const return QString(); } - engraving::Score* score = project->masterNotation()->masterScore(); + auto* score = project->masterNotation()->masterScore(); const double scoreTimeSeconds = std::max(0.0, static_cast(videoPositionMs - offsetMs()) / 1000.0); const int tick = std::max(0, score->utime2utick(scoreTimeSeconds)); const engraving::Measure* measure = score->tick2measure(engraving::Fraction::fromTicks(tick)); diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index 180bc915dde97..cea8a402e36c3 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -37,6 +37,14 @@ using namespace muse; static constexpr int VIDEO_SETTINGS_VERSION = 1; +static void normalizeHitPoints(VideoAttachmentSettings& attachment) +{ + std::stable_sort(attachment.hitPoints.begin(), attachment.hitPoints.end(), [](const VideoHitPointSettings& a, + const VideoHitPointSettings& b) { + return a.timeMs < b.timeMs; + }); +} + const VideoAttachmentSettings& ProjectVideoSettings::attachment() const { return m_attachment; @@ -44,11 +52,14 @@ const VideoAttachmentSettings& ProjectVideoSettings::attachment() const void ProjectVideoSettings::setAttachment(const VideoAttachmentSettings& attachment) { - if (m_attachment == attachment) { + VideoAttachmentSettings normalized = attachment; + normalizeHitPoints(normalized); + + if (m_attachment == normalized) { return; } - m_attachment = attachment; + m_attachment = normalized; m_settingsChanged.notify(); } @@ -152,9 +163,7 @@ VideoAttachmentSettings ProjectVideoSettings::attachmentFromJson(const QJsonObje } } - std::sort(result.hitPoints.begin(), result.hitPoints.end(), [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { - return a.timeMs < b.timeMs; - }); + normalizeHitPoints(result); return result; } From 8fa07699eeaf2ef2a68fbb253f303073572f17e4 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:08:17 -0400 Subject: [PATCH 18/28] Fix video panel CI configuration --- src/CMakeLists.txt | 2 +- .../qml/MuseScore/Playback/CMakeLists.txt | 19 ++++++++++++++----- src/project/internal/projectvideosettings.cpp | 9 +++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 40e91fa7d669e..6512bb6352d9c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,7 +19,7 @@ # along with this program. If not, see . if (MUE_BUILD_PLAYBACK_MODULE) - find_package(Qt6 REQUIRED COMPONENTS Multimedia) + find_package(Qt6 COMPONENTS Multimedia QUIET) endif() # Modules must be listed in dependency order diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index f49f819090d19..939dc164ad175 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -20,6 +20,16 @@ muse_create_qml_module(playback_qml FOR playback) +set(PLAYBACK_QML_IMPORTS + TARGET muse_ui_qml + TARGET muse_uicomponents_qml + TARGET notationscene_qml +) + +if (Qt6Multimedia_FOUND) + list(APPEND PLAYBACK_QML_IMPORTS QtMultimedia) +endif() + qt_add_qml_module(playback_qml URI MuseScore.Playback VERSION 1.0 @@ -85,12 +95,11 @@ qt_add_qml_module(playback_qml SoundProfilesDialog.qml VideoPanel.qml IMPORTS - QtMultimedia - TARGET muse_ui_qml - TARGET muse_uicomponents_qml - TARGET notationscene_qml + ${PLAYBACK_QML_IMPORTS} ) -target_link_libraries(playback_qml PRIVATE Qt6::Multimedia) +if (Qt6Multimedia_FOUND) + target_link_libraries(playback_qml PRIVATE Qt6::Multimedia) +endif() fixup_qml_module_dependencies(playback_qml) diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index cea8a402e36c3..260478a59f5d0 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -39,10 +39,11 @@ static constexpr int VIDEO_SETTINGS_VERSION = 1; static void normalizeHitPoints(VideoAttachmentSettings& attachment) { - std::stable_sort(attachment.hitPoints.begin(), attachment.hitPoints.end(), [](const VideoHitPointSettings& a, - const VideoHitPointSettings& b) { - return a.timeMs < b.timeMs; - }); + std::stable_sort( + attachment.hitPoints.begin(), attachment.hitPoints.end(), + [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { + return a.timeMs < b.timeMs; + }); } const VideoAttachmentSettings& ProjectVideoSettings::attachment() const From 2f48f971f68025368d3881ebdf402cb969970047 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:10:45 -0400 Subject: [PATCH 19/28] Refine video panel timecode UI --- .../internal/applicationuiactions.cpp | 72 ++++++ src/appshell/internal/applicationuiactions.h | 3 + .../qml/MuseScore/AppShell/appmenumodel.cpp | 52 ++++- .../qml/MuseScore/AppShell/appmenumodel.h | 3 + .../MuseScore/AppShell/notationpagemodel.cpp | 32 +++ .../MuseScore/AppShell/notationpagemodel.h | 2 + .../qml/MuseScore/Playback/VideoPanel.qml | 210 ++++++++++-------- .../MuseScore/Playback/videopanelmodel.cpp | 19 ++ .../qml/MuseScore/Playback/videopanelmodel.h | 1 + 9 files changed, 297 insertions(+), 97 deletions(-) diff --git a/src/appshell/internal/applicationuiactions.cpp b/src/appshell/internal/applicationuiactions.cpp index d862972cb5150..0fe963e0ee8c6 100644 --- a/src/appshell/internal/applicationuiactions.cpp +++ b/src/appshell/internal/applicationuiactions.cpp @@ -24,6 +24,7 @@ #include "ui/view/iconcodes.h" #include "context/uicontext.h" #include "context/shortcutcontext.h" +#include "project/iprojectvideosettings.h" #include "dockwindow/idockwindow.h" #include "async/notification.h" @@ -40,6 +41,18 @@ static const ActionCode FULL_SCREEN_CODE("fullscreen"); static const ActionCode TOGGLE_NAVIGATOR_ACTION_CODE("toggle-navigator"); static const ActionCode TOGGLE_BRAILLE_ACTION_CODE("toggle-braille-panel"); static const ActionCode TOGGLE_PERCUSSION_PANEL_ACTION_CODE("toggle-percussion-panel"); +static const ActionCode VIDEO_TIMECODE_OFF_CODE("video-timecode-off"); +static const ActionCode VIDEO_TIMECODE_ABOVE_CODE("video-timecode-above-bars"); +static const ActionCode VIDEO_TIMECODE_BELOW_CODE("video-timecode-below-bars"); + +static ActionCodeList videoTimecodeActionCodes() +{ + return { + VIDEO_TIMECODE_OFF_CODE, + VIDEO_TIMECODE_ABOVE_CODE, + VIDEO_TIMECODE_BELOW_CODE + }; +} const UiActionList ApplicationUiActions::m_actions = { UiAction("quit", @@ -207,6 +220,27 @@ const UiActionList ApplicationUiActions::m_actions = { TranslatableString("action", "Show/hide video panel"), Checkable::Yes ), + UiAction(VIDEO_TIMECODE_OFF_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_ANY, + TranslatableString("action", "&Off"), + TranslatableString("action", "Hide video timecode"), + Checkable::Yes + ), + UiAction(VIDEO_TIMECODE_ABOVE_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_ANY, + TranslatableString("action", "&Above bars"), + TranslatableString("action", "Show video timecode above bars"), + Checkable::Yes + ), + UiAction(VIDEO_TIMECODE_BELOW_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_ANY, + TranslatableString("action", "&Below bars"), + TranslatableString("action", "Show video timecode below bars"), + Checkable::Yes + ), UiAction(TOGGLE_PERCUSSION_PANEL_ACTION_CODE, mu::context::UiCtxProjectOpened, mu::context::CTX_NOTATION_OPENED, @@ -314,6 +348,13 @@ void ApplicationUiActions::init() dockWindowProvider()->windowChanged().onNotify(this, [this]() { listenOpenedDocksChanged(dockWindowProvider()->window()); }); + + globalContext()->currentProjectChanged().onNotify(this, [this]() { + listenCurrentProjectVideoSettings(); + m_actionCheckedChanged.send(videoTimecodeActionCodes()); + }); + + listenCurrentProjectVideoSettings(); } void ApplicationUiActions::listenOpenedDocksChanged(IDockWindow* window) @@ -355,6 +396,24 @@ bool ApplicationUiActions::actionChecked(const UiAction& act) const return mainWindow()->isFullScreen(); } + if (act.code == VIDEO_TIMECODE_OFF_CODE || act.code == VIDEO_TIMECODE_ABOVE_CODE || act.code == VIDEO_TIMECODE_BELOW_CODE) { + project::INotationProjectPtr project = globalContext()->currentProject(); + project::IProjectVideoSettingsPtr settings = project ? project->videoSettings() : nullptr; + const project::VideoTimecodeDisplayMode mode = settings + ? settings->attachment().timecodeDisplayMode + : project::VideoTimecodeDisplayMode::Off; + + if (act.code == VIDEO_TIMECODE_OFF_CODE) { + return mode == project::VideoTimecodeDisplayMode::Off; + } + + if (act.code == VIDEO_TIMECODE_ABOVE_CODE) { + return mode == project::VideoTimecodeDisplayMode::AboveBars; + } + + return mode == project::VideoTimecodeDisplayMode::BelowBars; + } + QMap toggleDockActions = ApplicationUiActions::toggleDockActions(); DockName dockName = toggleDockActions.value(act.code, DockName()); @@ -384,6 +443,19 @@ muse::async::Channel ApplicationUiActions::actionCheckedChanged( return m_actionCheckedChanged; } +void ApplicationUiActions::listenCurrentProjectVideoSettings() +{ + project::INotationProjectPtr project = globalContext()->currentProject(); + project::IProjectVideoSettingsPtr settings = project ? project->videoSettings() : nullptr; + if (!settings) { + return; + } + + settings->settingsChanged().onNotify(this, [this]() { + m_actionCheckedChanged.send(videoTimecodeActionCodes()); + }, Asyncable::Mode::SetReplace); +} + const QMap& ApplicationUiActions::toggleDockActions() { static const QMap actionsMap { diff --git a/src/appshell/internal/applicationuiactions.h b/src/appshell/internal/applicationuiactions.h index 0213478294272..fc084cac72c10 100644 --- a/src/appshell/internal/applicationuiactions.h +++ b/src/appshell/internal/applicationuiactions.h @@ -28,6 +28,7 @@ #include "async/asyncable.h" #include "ui/imainwindow.h" #include "braille/ibrailleconfiguration.h" +#include "context/iglobalcontext.h" #include "dockwindow/idockwindowprovider.h" @@ -38,6 +39,7 @@ class ApplicationUiActions : public muse::ui::IUiActionsModule, public muse::Con { muse::GlobalInject brailleConfiguration; muse::ContextInject appShellState = { this }; + muse::ContextInject globalContext = { this }; muse::ContextInject mainWindow = { this }; muse::ContextInject dockWindowProvider = { this }; @@ -58,6 +60,7 @@ class ApplicationUiActions : public muse::ui::IUiActionsModule, public muse::Con private: void listenOpenedDocksChanged(muse::dock::IDockWindow* window); + void listenCurrentProjectVideoSettings(); static const muse::ui::UiActionList m_actions; diff --git a/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp b/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp index 298408a8af71f..727fe0bdc3906 100644 --- a/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp +++ b/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp @@ -41,6 +41,9 @@ using namespace muse::actions; using namespace muse::extensions; static const ActionCode TOGGLE_UNDO_HISTORY_PANEL_CODE = "toggle-undo-history-panel"; +static const ActionCode APP_MENU_VIDEO_TIMECODE_OFF_CODE = "video-timecode-off"; +static const ActionCode APP_MENU_VIDEO_TIMECODE_ABOVE_CODE = "video-timecode-above-bars"; +static const ActionCode APP_MENU_VIDEO_TIMECODE_BELOW_CODE = "video-timecode-below-bars"; static const QString VIEW_TOGGLE_UNDO_HISTORY_PANEL_ITEM_ID = "view/toggle-undo-history-panel"; static QString makeId(const ActionCode& actionCode, int itemIndex) @@ -143,6 +146,22 @@ void AppMenuModel::setupConnections() updateUndoRedoItems(); }); + + globalContext()->currentProjectChanged().onNotify(this, [this]() { + updateTimecodeItems(); + + project::INotationProjectPtr project = globalContext()->currentProject(); + project::IProjectVideoSettingsPtr settings = project ? project->videoSettings() : nullptr; + if (!settings) { + return; + } + + settings->settingsChanged().onNotify(this, [this]() { + updateTimecodeItems(); + }, Asyncable::Mode::SetReplace); + }); + + updateTimecodeItems(); } void AppMenuModel::onActionsStateChanges(const muse::actions::ActionCodeList& codes) @@ -313,7 +332,8 @@ MenuItem* AppMenuModel::makeViewMenu() makeMenuItem("playback-setup"), //makeMenuItem("toggle-scorecmp-tool"), // not implemented makeSeparator(), - makeMenu(TranslatableString("appshell/menu/view", "&Toolbars"), makeToolbarsItems(), "menu-toolbars") + makeMenu(TranslatableString("appshell/menu/view", "&Toolbars"), makeToolbarsItems(), "menu-toolbars"), + makeMenu(TranslatableString("appshell/menu/view", "Video &timecode"), makeTimecodeItems(), "menu-video-timecode") }; #ifdef MUSE_MODULE_WORKSPACE @@ -791,6 +811,36 @@ MenuItemList AppMenuModel::makeToolbarsItems() return items; } +MenuItemList AppMenuModel::makeTimecodeItems() +{ + project::INotationProjectPtr project = globalContext()->currentProject(); + project::IProjectVideoSettingsPtr settings = project ? project->videoSettings() : nullptr; + const VideoTimecodeDisplayMode mode = settings + ? settings->attachment().timecodeDisplayMode + : VideoTimecodeDisplayMode::Off; + + auto makeTimecodeItem = [this, mode](const ActionCode& actionCode, VideoTimecodeDisplayMode itemMode) { + MenuItem* item = makeMenuItem(actionCode); + item->setSelectable(true); + item->setSelected(mode == itemMode); + return item; + }; + + return { + makeTimecodeItem(APP_MENU_VIDEO_TIMECODE_OFF_CODE, VideoTimecodeDisplayMode::Off), + makeTimecodeItem(APP_MENU_VIDEO_TIMECODE_ABOVE_CODE, VideoTimecodeDisplayMode::AboveBars), + makeTimecodeItem(APP_MENU_VIDEO_TIMECODE_BELOW_CODE, VideoTimecodeDisplayMode::BelowBars) + }; +} + +void AppMenuModel::updateTimecodeItems() +{ + MenuItem& timecodeMenu = findMenu("menu-video-timecode"); + if (timecodeMenu.isValid()) { + timecodeMenu.setSubitems(makeTimecodeItems()); + } +} + MenuItemList AppMenuModel::makeShowItems() { MenuItemList items { diff --git a/src/appshell/qml/MuseScore/AppShell/appmenumodel.h b/src/appshell/qml/MuseScore/AppShell/appmenumodel.h index 418255966f79d..5859f0ab02e70 100644 --- a/src/appshell/qml/MuseScore/AppShell/appmenumodel.h +++ b/src/appshell/qml/MuseScore/AppShell/appmenumodel.h @@ -32,6 +32,7 @@ #include "context/iglobalcontext.h" #include "extensions/iextensionsprovider.h" #include "global/iglobalconfiguration.h" +#include "project/iprojectvideosettings.h" #ifdef MUSE_MODULE_MUSESAMPLER #include "musesampler/imusesamplerinfo.h" #endif @@ -87,6 +88,7 @@ class AppMenuModel : public muse::uicomponents::AbstractMenuModel void setupConnections(); void onActionsStateChanges(const muse::actions::ActionCodeList& codes) override; + void updateTimecodeItems(); bool isMuseSamplerModuleAdded() const; @@ -120,6 +122,7 @@ class AppMenuModel : public muse::uicomponents::AbstractMenuModel muse::uicomponents::MenuItemList makeLinesItems(); muse::uicomponents::MenuItemList makeChordAndFretboardDiagramsItems(); muse::uicomponents::MenuItemList makeToolbarsItems(); + muse::uicomponents::MenuItemList makeTimecodeItems(); muse::uicomponents::MenuItemList makeWorkspacesItems(); muse::uicomponents::MenuItemList makeShowItems(); muse::uicomponents::MenuItemList makePluginsItems(); diff --git a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp index a4cbaff3be268..6daf478e6aad7 100644 --- a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp +++ b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp @@ -30,8 +30,13 @@ using namespace mu::appshell; using namespace mu::notation; +using namespace mu::project; using namespace muse::actions; +static const ActionCode NOTATION_PAGE_VIDEO_TIMECODE_OFF_CODE("video-timecode-off"); +static const ActionCode NOTATION_PAGE_VIDEO_TIMECODE_ABOVE_CODE("video-timecode-above-bars"); +static const ActionCode NOTATION_PAGE_VIDEO_TIMECODE_BELOW_CODE("video-timecode-below-bars"); + NotationPageModel::NotationPageModel(QObject* parent) : QObject(parent), muse::Contextable(muse::iocCtxForQmlObject(this)) { @@ -60,6 +65,16 @@ void NotationPageModel::init() dispatcher()->reg(this, actionCode, [this, dockName]() { toggleDock(dockName); }); } + dispatcher()->reg(this, NOTATION_PAGE_VIDEO_TIMECODE_OFF_CODE, [this]() { + setVideoTimecodeDisplayMode(VideoTimecodeDisplayMode::Off); + }); + dispatcher()->reg(this, NOTATION_PAGE_VIDEO_TIMECODE_ABOVE_CODE, [this]() { + setVideoTimecodeDisplayMode(VideoTimecodeDisplayMode::AboveBars); + }); + dispatcher()->reg(this, NOTATION_PAGE_VIDEO_TIMECODE_BELOW_CODE, [this]() { + setVideoTimecodeDisplayMode(VideoTimecodeDisplayMode::BelowBars); + }); + globalContext()->currentNotationChanged().onNotify(this, [this]() { onNotationChanged(); scheduleUpdatePercussionPanelVisibility(); @@ -204,6 +219,23 @@ void NotationPageModel::toggleDock(const QString& name) dispatcher()->dispatch("dock-toggle", ActionData::make_arg1(name)); } +void NotationPageModel::setVideoTimecodeDisplayMode(VideoTimecodeDisplayMode mode) +{ + INotationProjectPtr project = globalContext()->currentProject(); + IProjectVideoSettingsPtr settings = project ? project->videoSettings() : nullptr; + if (!settings || !settings->attachment().isValid()) { + return; + } + + VideoAttachmentSettings updated = settings->attachment(); + if (updated.timecodeDisplayMode == mode) { + return; + } + + updated.timecodeDisplayMode = mode; + settings->setAttachment(updated); +} + void NotationPageModel::scheduleUpdatePercussionPanelVisibility() { if (m_updatePercussionPanelVisibilityScheduled) { diff --git a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h index 8762ed762a4f0..ecc7143b76c6e 100644 --- a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h +++ b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.h @@ -32,6 +32,7 @@ #include "dockwindow/idockwindowprovider.h" #include "extensions/iextensionsprovider.h" #include "context/iglobalcontext.h" +#include "project/iprojectvideosettings.h" #include "notationscene/inotationsceneconfiguration.h" #include "braille/ibrailleconfiguration.h" #include "iappshellstate.h" @@ -90,6 +91,7 @@ class NotationPageModel : public QObject, public muse::Contextable, public muse: void onNotationChanged(); void toggleDock(const QString& name); + void setVideoTimecodeDisplayMode(project::VideoTimecodeDisplayMode mode); void scheduleUpdatePercussionPanelVisibility(); void doUpdatePercussionPanelVisibility(); diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index d7ee42d798482..5dfed896546bc 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -23,6 +23,7 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Controls import QtQuick.Layouts import QtMultimedia @@ -38,7 +39,6 @@ Item { readonly property int contentMargin: 8 readonly property bool compactMode: width < 620 - readonly property int controlsHeight: controlsColumn.implicitHeight clip: true @@ -112,8 +112,8 @@ Item { Layout.maximumWidth: 16777215 Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredHeight: Math.max(140, root.height - root.controlsHeight - (root.contentMargin * 2) - 8) - Layout.minimumHeight: 72 + Layout.preferredHeight: root.compactMode ? 180 : 260 + Layout.minimumHeight: 96 radius: 4 color: "#111111" @@ -335,70 +335,6 @@ Item { } } - RowLayout { - Layout.fillWidth: true - spacing: 8 - - StyledTextLabel { - text: qsTrc("playback", "Timecode") - } - - FlatButton { - text: qsTrc("playback", "Off") - enabled: videoModel.hasVideo - accentButton: videoModel.timecodeDisplayMode === 0 - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 9 - - onClicked: { - videoModel.timecodeDisplayMode = 0 - } - } - - FlatButton { - text: qsTrc("playback", "Above") - enabled: videoModel.hasVideo - accentButton: videoModel.timecodeDisplayMode === 1 - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 10 - - onClicked: { - videoModel.timecodeDisplayMode = 1 - } - } - - FlatButton { - text: qsTrc("playback", "Below") - enabled: videoModel.hasVideo - accentButton: videoModel.timecodeDisplayMode === 2 - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 11 - - onClicked: { - videoModel.timecodeDisplayMode = 2 - } - } - - StyledTextLabel { - text: qsTrc("playback", "fps") - } - - TextInputField { - Layout.preferredWidth: 56 - currentText: videoModel.frameRate.toString() - enabled: videoModel.hasVideo - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 12 - - onTextEditingFinished: function(newTextValue) { - var parsedValue = parseFloat(newTextValue) - if (!isNaN(parsedValue)) { - videoModel.frameRate = parsedValue - } - } - } - } - ColumnLayout { Layout.fillWidth: true spacing: 4 @@ -413,11 +349,30 @@ Item { text: videoModel.hasVideo ? videoModel.formatTimecode(video.position) : "" } + StyledTextLabel { + text: qsTrc("playback", "fps") + } + + TextInputField { + Layout.preferredWidth: 56 + currentText: videoModel.frameRate.toString() + enabled: videoModel.hasVideo + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 9 + + onTextEditingFinished: function(newTextValue) { + var parsedValue = parseFloat(newTextValue) + if (!isNaN(parsedValue)) { + videoModel.frameRate = parsedValue + } + } + } + FlatButton { text: qsTrc("playback", "Add hit point") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 13 + navigation.order: root.contentNavigationPanelOrderStart + 10 onClicked: { videoModel.addHitPoint(video.position) @@ -425,38 +380,101 @@ Item { } } - Repeater { - model: videoModel.hitPoints - - RowLayout { - required property var modelData - required property int index - - Layout.fillWidth: true - spacing: 8 + StyledFlickable { + id: hitPointsFlickable - StyledTextLabel { - Layout.preferredWidth: 92 - text: parent.modelData.timecode - maximumLineCount: 1 - } + Layout.fillWidth: true + Layout.preferredHeight: Math.min(hitPointsColumn.implicitHeight, 132) + Layout.maximumHeight: 132 + visible: videoModel.hitPoints.length > 0 - StyledTextLabel { - Layout.fillWidth: true - text: parent.modelData.label + " " + parent.modelData.musicalPosition - maximumLineCount: 1 - } + contentHeight: hitPointsColumn.implicitHeight + interactive: contentHeight > height + clip: true - FlatButton { - Layout.preferredWidth: 32 - Layout.preferredHeight: 28 - icon: IconCode.DELETE_TANK - buttonType: FlatButton.IconOnly - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 14 + parent.index + ScrollBar.vertical: StyledScrollBar { + policy: hitPointsFlickable.contentHeight > hitPointsFlickable.height ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded + padding: 0 + } - onClicked: { - videoModel.removeHitPoint(parent.index) + ColumnLayout { + id: hitPointsColumn + + width: hitPointsFlickable.width - (hitPointsFlickable.contentHeight > hitPointsFlickable.height ? 12 : 0) + spacing: 4 + + Repeater { + model: videoModel.hitPoints + + RowLayout { + id: hitPointDelegate + + required property var modelData + required property int index + + property bool editingLabel: false + + width: hitPointsColumn.width + spacing: 8 + + StyledTextLabel { + Layout.preferredWidth: 92 + text: hitPointDelegate.modelData.timecode + maximumLineCount: 1 + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 28 + + StyledTextLabel { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: hitPointDelegate.modelData.label + " " + hitPointDelegate.modelData.musicalPosition + maximumLineCount: 1 + visible: !hitPointDelegate.editingLabel + + MouseArea { + anchors.fill: parent + onClicked: { + hitPointDelegate.editingLabel = true + labelEditor.forceActiveFocus() + } + } + } + + TextInputField { + id: labelEditor + + anchors.fill: parent + currentText: hitPointDelegate.modelData.label + visible: hitPointDelegate.editingLabel + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 11 + hitPointDelegate.index + + onTextEditingFinished: function(newTextValue) { + videoModel.renameHitPoint(hitPointDelegate.index, newTextValue) + hitPointDelegate.editingLabel = false + } + + Keys.onEscapePressed: { + hitPointDelegate.editingLabel = false + } + } + } + + FlatButton { + Layout.preferredWidth: 32 + Layout.preferredHeight: 28 + icon: IconCode.DELETE_TANK + buttonType: FlatButton.IconOnly + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 18 + hitPointDelegate.index + + onClicked: { + videoModel.removeHitPoint(hitPointDelegate.index) + } + } } } } diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index a220bf92fbe3d..4467f27d18eb5 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -98,6 +98,25 @@ void VideoPanelModel::removeHitPoint(int index) updateAttachment(updated); } +void VideoPanelModel::renameHitPoint(int index, const QString& label) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || index < 0 || index >= static_cast(updated.hitPoints.size())) { + return; + } + + const QString trimmedLabel = label.trimmed(); + const muse::String newLabel = trimmedLabel.isEmpty() + ? muse::String(u"Hit %1").arg(index + 1) + : muse::String::fromQString(trimmedLabel); + if (updated.hitPoints.at(index).label == newLabel) { + return; + } + + updated.hitPoints[index].label = newLabel; + updateAttachment(updated); +} + QString VideoPanelModel::formatTimecode(int videoPositionMs) const { videoPositionMs = std::max(0, videoPositionMs); diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 2ba4d0fad5a5b..693cf736309ce 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -65,6 +65,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_INVOKABLE void nudgeOffset(int deltaMs); Q_INVOKABLE void addHitPoint(int videoPositionMs); Q_INVOKABLE void removeHitPoint(int index); + Q_INVOKABLE void renameHitPoint(int index, const QString& label); Q_INVOKABLE QString formatTimecode(int videoPositionMs) const; bool hasVideo() const; From 4e32b814d7792ef078cd18f4b01973e726363ed2 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:15:53 -0400 Subject: [PATCH 20/28] Guard mixer delegates during model refresh --- .../Playback/internal/MixerPanelSection.qml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml b/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml index f02452dbcdd8e..6e99c814d1b27 100644 --- a/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml +++ b/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml @@ -88,7 +88,23 @@ Loader { spacing: 1 // for separators (will be rendered in MixerPanel.qml) model: root.model - delegate: root.delegateComponent + delegate: Item { + id: delegateRoot + + required property var channelItem + + width: root.channelItemWidth + height: contentLoader.item ? contentLoader.item.height : 1 + + Loader { + id: contentLoader + + property var channelItem: delegateRoot.channelItem + + active: Boolean(channelItem) + sourceComponent: root.delegateComponent + } + } } } } From 429190e27ff0e03f89d1f70e4015b346f8ee2c77 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:18:20 -0400 Subject: [PATCH 21/28] Revert mixer delegate guard --- .../Playback/internal/MixerPanelSection.qml | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml b/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml index 6e99c814d1b27..f02452dbcdd8e 100644 --- a/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml +++ b/src/playback/qml/MuseScore/Playback/internal/MixerPanelSection.qml @@ -88,23 +88,7 @@ Loader { spacing: 1 // for separators (will be rendered in MixerPanel.qml) model: root.model - delegate: Item { - id: delegateRoot - - required property var channelItem - - width: root.channelItemWidth - height: contentLoader.item ? contentLoader.item.height : 1 - - Loader { - id: contentLoader - - property var channelItem: delegateRoot.channelItem - - active: Boolean(channelItem) - sourceComponent: root.delegateComponent - } - } + delegate: root.delegateComponent } } } From 9388feccfcecf54f5042b4d3efb39a504178c4a1 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:23:31 -0400 Subject: [PATCH 22/28] Improve video timeline hit markers --- .../qml/MuseScore/Playback/VideoPanel.qml | 106 ++++++++++++++---- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 5dfed896546bc..8a7934fa6dc80 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -39,6 +39,7 @@ Item { readonly property int contentMargin: 8 readonly property bool compactMode: width < 620 + readonly property color hitPointColor: "#3B94E5" clip: true @@ -56,6 +57,12 @@ Item { return Math.max(0, Math.min(video.duration, videoModel.scorePlaybackPositionMs + videoModel.offsetMs)) } + function clearAttachedVideo() { + video.stop() + video.source = "" + videoModel.clearVideo() + } + function syncVideoToScore(forceSeek) { if (!videoModel.hasVideo || video.duration <= 0) { return @@ -185,7 +192,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: 28 + height: 36 visible: videoModel.hasVideo && video.duration > 0 && videoModel.hitPoints.length > 0 Rectangle { @@ -193,22 +200,49 @@ Item { color: "#99000000" } + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 2 + color: "#66FFFFFF" + } + Repeater { model: videoModel.hitPoints - Rectangle { + Item { required property var modelData x: Math.max(0, Math.min(parent.width - width, (modelData.timeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) - y: 3 - width: 2 - height: parent.height - 6 - color: "#" + ("000000" + modelData.color.toString(16)).slice(-6) + width: Math.max(20, hitLabel.implicitWidth + 8) + height: parent.height + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.bottom: hitLabel.top + width: 2 + color: root.hitPointColor + } - StyledTextLabel { - anchors.left: parent.right - anchors.leftMargin: 3 + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter + width: 8 + height: 8 + radius: 4 + color: root.hitPointColor + border.width: 1 + border.color: "white" + } + + StyledTextLabel { + id: hitLabel + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 2 text: parent.modelData.label maximumLineCount: 1 font.pixelSize: 10 @@ -249,25 +283,50 @@ Item { } } - StyledSlider { + Item { Layout.fillWidth: true - from: 0 - to: Math.max(video.duration, 1) - stepSize: 100 - value: video.position - enabled: videoModel.hasVideo && video.seekable - navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 3 + Layout.preferredHeight: 30 - onMoved: { - video.seek(value) + StyledSlider { + id: positionSlider + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + from: 0 + to: Math.max(video.duration, 1) + stepSize: 100 + value: video.position + enabled: videoModel.hasVideo && video.seekable + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 3 + + onMoved: { + video.seek(value) + } + } + + Repeater { + model: videoModel.hitPoints + + Rectangle { + required property var modelData + + x: Math.max(0, Math.min(parent.width - width, (modelData.timeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) + y: 2 + width: 3 + height: parent.height - 4 + radius: 1 + visible: videoModel.hasVideo && video.duration > 0 + color: root.hitPointColor + } } } StyledTextLabel { - Layout.preferredWidth: root.compactMode ? 64 : 92 + Layout.preferredWidth: root.compactMode ? 86 : 116 horizontalAlignment: Text.AlignRight - text: videoModel.hasVideo ? Math.floor(video.position / 1000) + " / " + Math.floor(video.duration / 1000) + " s" : "" + text: videoModel.hasVideo ? videoModel.formatTimecode(video.position) : "" } } @@ -346,7 +405,7 @@ Item { StyledTextLabel { Layout.fillWidth: true - text: videoModel.hasVideo ? videoModel.formatTimecode(video.position) : "" + text: videoModel.hasVideo ? videoModel.hitPoints.length + " " + qsTrc("playback", "hit points") : "" } StyledTextLabel { @@ -495,8 +554,7 @@ Item { navigation.order: root.contentNavigationPanelOrderStart + 30 onClicked: { - video.stop() - videoModel.clearVideo() + root.clearAttachedVideo() } } } From 5416a7ca6867f86f8465f7a11f8808c316839304 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:51:50 -0400 Subject: [PATCH 23/28] Improve video scoring panel behavior --- .../AppShell/NotationPage/NotationPage.qml | 5 +- src/engraving/style/styledef.cpp | 23 + src/engraving/style/styledef.h | 23 + src/engraving/style/textstyle.cpp | 24 + src/engraving/types/types.h | 1 + src/engraving/types/typesconv.cpp | 2 + .../abstractnotationpaintview.cpp | 176 ++++++ .../NotationScene/abstractnotationpaintview.h | 2 + src/notationscene/widgets/editstyle.cpp | 89 ++- src/notationscene/widgets/editstyle.h | 14 + src/notationscene/widgets/editstyleutils.cpp | 3 + .../qml/MuseScore/Playback/VideoPanel.qml | 561 ++++++++++++++---- .../MuseScore/Playback/mixerpanelmodel.cpp | 4 +- .../MuseScore/Playback/videopanelmodel.cpp | 67 +++ .../qml/MuseScore/Playback/videopanelmodel.h | 3 + 15 files changed, 872 insertions(+), 125 deletions(-) diff --git a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml index 2432698f43f6d..7093741e5bd6b 100644 --- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml +++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml @@ -570,8 +570,8 @@ DockPage { objectName: root.pageModel.videoPanelName() title: qsTrc("appshell", "Video") - height: 150 - minimumHeight: 96 + height: 360 + minimumHeight: root.horizontalPanelMinHeight maximumHeight: root.horizontalPanelMaxHeight minimumWidth: root.panelMinDimension @@ -589,6 +589,7 @@ DockPage { navigationSection: root.navigationPanelSec(videoPanel.location) VideoPanel { + anchors.fill: parent navigationSection: videoPanel.navigationSection contentNavigationPanelOrderStart: videoPanel.contentNavigationPanelOrderStart } diff --git a/src/engraving/style/styledef.cpp b/src/engraving/style/styledef.cpp index 625352d0bca69..6bc1fd1f64406 100644 --- a/src/engraving/style/styledef.cpp +++ b/src/engraving/style/styledef.cpp @@ -1360,6 +1360,24 @@ const std::array StyleDef::styleValue styleDef(systemTextFrameBgColor, PropertyValue::fromValue(Color::transparent)), styleDef(systemTextPosition, AlignH::LEFT), + styleDef(videoHitPointFontFace, "Edwin"), + styleDef(videoHitPointFontSize, 60.0), + styleDef(videoHitPointLineSpacing, 1.0), + styleDef(videoHitPointFontSpatiumDependent, false), + styleDef(videoHitPointFontStyle, int(FontStyle::Normal)), + styleDef(videoHitPointColor, PropertyValue::fromValue(Color::BLACK)), + styleDef(videoHitPointAlign, Align(AlignH::HCENTER, AlignV::BOTTOM)), + styleDef(videoHitPointOffsetType, int(OffsetType::SPATIUM)), + styleDef(videoHitPointPosAbove, PointF(.0, -0.35)), + styleDef(videoHitPointPosBelow, PointF(.0, 1.0)), + styleDef(videoHitPointFrameType, int(FrameType::NO_FRAME)), + styleDef(videoHitPointFramePadding, 0.2_sp), + styleDef(videoHitPointFrameWidth, 0.1_sp), + styleDef(videoHitPointFrameRound, 0.0_sp), + styleDef(videoHitPointFrameFgColor, PropertyValue::fromValue(Color::BLACK)), + styleDef(videoHitPointFrameBgColor, PropertyValue::fromValue(Color::transparent)), + styleDef(videoHitPointPosition, AlignH::HCENTER), + styleDef(staffTextFontFace, "Edwin"), styleDef(staffTextFontSize, 10.0), styleDef(staffTextLineSpacing, 1.0), @@ -2222,6 +2240,11 @@ const std::array StyleDef::styleValue styleDef(palmMuteBeginFilledArrowWidth, 0.85_sp), styleDef(palmMuteEndFilledArrowHeight, 1.0_sp), styleDef(palmMuteEndFilledArrowWidth, 0.85_sp), + + styleDef(videoHitPointLabelFontSize, 5.0), + styleDef(videoHitPointLineStyle, PropertyValue(LineType::SOLID)), + styleDef(videoHitPointLineTransparency, 30), + styleDef(videoHitPointLineColor, PropertyValue::fromValue(Color(59, 148, 229))), } }; #undef styleDef diff --git a/src/engraving/style/styledef.h b/src/engraving/style/styledef.h index b896c919ce07e..0b33c2597572b 100644 --- a/src/engraving/style/styledef.h +++ b/src/engraving/style/styledef.h @@ -1368,6 +1368,24 @@ enum class Sid : short { systemTextFrameBgColor, systemTextPosition, + videoHitPointFontFace, + videoHitPointFontSize, + videoHitPointLineSpacing, + videoHitPointFontSpatiumDependent, + videoHitPointFontStyle, + videoHitPointColor, + videoHitPointAlign, + videoHitPointOffsetType, + videoHitPointPosAbove, + videoHitPointPosBelow, + videoHitPointFrameType, + videoHitPointFramePadding, + videoHitPointFrameWidth, + videoHitPointFrameRound, + videoHitPointFrameFgColor, + videoHitPointFrameBgColor, + videoHitPointPosition, + staffTextFontFace, staffTextFontSize, staffTextLineSpacing, @@ -2241,6 +2259,11 @@ enum class Sid : short { palmMuteEndFilledArrowHeight, palmMuteEndFilledArrowWidth, + videoHitPointLabelFontSize, + videoHitPointLineStyle, + videoHitPointLineTransparency, + videoHitPointLineColor, + STYLES }; diff --git a/src/engraving/style/textstyle.cpp b/src/engraving/style/textstyle.cpp index ffb2bf8fafd2d..097724c382a7e 100644 --- a/src/engraving/style/textstyle.cpp +++ b/src/engraving/style/textstyle.cpp @@ -775,6 +775,28 @@ const TextStyle systemTextStyle { { Sid::systemTextPosAbove, Sid::systemTextPosBelow } }; +const TextStyle videoHitPointTextStyle { + { { + { TextStylePropertyType::FontFace, Sid::videoHitPointFontFace, Pid::FONT_FACE }, + { TextStylePropertyType::FontSize, Sid::videoHitPointFontSize, Pid::FONT_SIZE }, + { TextStylePropertyType::LineSpacing, Sid::videoHitPointLineSpacing, Pid::TEXT_LINE_SPACING }, + { TextStylePropertyType::SizeSpatiumDependent, Sid::videoHitPointFontSpatiumDependent, Pid::SIZE_SPATIUM_DEPENDENT }, + { TextStylePropertyType::FontStyle, Sid::videoHitPointFontStyle, Pid::FONT_STYLE }, + { TextStylePropertyType::Color, Sid::videoHitPointColor, Pid::COLOR }, + { TextStylePropertyType::TextAlign, Sid::videoHitPointAlign, Pid::ALIGN }, + { TextStylePropertyType::FrameType, Sid::videoHitPointFrameType, Pid::FRAME_TYPE }, + { TextStylePropertyType::FramePadding, Sid::videoHitPointFramePadding, Pid::FRAME_PADDING }, + { TextStylePropertyType::FrameWidth, Sid::videoHitPointFrameWidth, Pid::FRAME_WIDTH }, + { TextStylePropertyType::FrameRound, Sid::videoHitPointFrameRound, Pid::FRAME_ROUND }, + { TextStylePropertyType::FrameBorderColor, Sid::videoHitPointFrameFgColor, Pid::FRAME_FG_COLOR }, + { TextStylePropertyType::FrameFillColor, Sid::videoHitPointFrameBgColor, Pid::FRAME_BG_COLOR }, + { TextStylePropertyType::MusicalSymbolsScale, Sid::dummyMusicalSymbolsScale, Pid::MUSICAL_SYMBOLS_SCALE }, + { TextStylePropertyType::MusicalSymbolsSize, Sid::defaultMusicalSymbolSize, Pid::MUSIC_SYMBOL_SIZE }, + { TextStylePropertyType::Position, Sid::videoHitPointPosition, Pid::POSITION }, + } }, + { Sid::videoHitPointPosAbove, Sid::videoHitPointPosBelow } +}; + const TextStyle staffTextStyle { { { { TextStylePropertyType::FontFace, Sid::staffTextFontFace, Pid::FONT_FACE }, @@ -1688,6 +1710,7 @@ const TextStyle* textStyle(TextStyleType idx) case TextStyleType::REPEAT_RIGHT: return &repeatRightTextStyle; case TextStyleType::REHEARSAL_MARK: return &rehearsalMarkTextStyle; case TextStyleType::SYSTEM: return &systemTextStyle; + case TextStyleType::VIDEO_HIT_POINT: return &videoHitPointTextStyle; case TextStyleType::STAFF: return &staffTextStyle; case TextStyleType::EXPRESSION: return &expressionTextStyle; @@ -1772,6 +1795,7 @@ static const std::vector _primaryTextStyles = { TextStyleType::REPEAT_RIGHT, TextStyleType::REHEARSAL_MARK, TextStyleType::SYSTEM, + TextStyleType::VIDEO_HIT_POINT, TextStyleType::STAFF, TextStyleType::EXPRESSION, TextStyleType::DYNAMICS, diff --git a/src/engraving/types/types.h b/src/engraving/types/types.h index 8846268ffb7b9..e607772f3c6ea 100644 --- a/src/engraving/types/types.h +++ b/src/engraving/types/types.h @@ -863,6 +863,7 @@ enum class TextStyleType : unsigned char { REPEAT_RIGHT, // align to end of measure REHEARSAL_MARK, SYSTEM, + VIDEO_HIT_POINT, // Staff oriented styles STAFF, diff --git a/src/engraving/types/typesconv.cpp b/src/engraving/types/typesconv.cpp index 49b761be900f4..bc8f8821221d0 100644 --- a/src/engraving/types/typesconv.cpp +++ b/src/engraving/types/typesconv.cpp @@ -1752,6 +1752,7 @@ static const std::vector > TEXTSTYLE_TYPES = { { TextStyleType::REPEAT_RIGHT, "repeat_right", muse::TranslatableString("engraving", "Repeat text right") }, { TextStyleType::REHEARSAL_MARK, "rehearsal_mark", muse::TranslatableString("engraving", "Rehearsal mark") }, { TextStyleType::SYSTEM, "system", muse::TranslatableString("engraving", "System") }, + { TextStyleType::VIDEO_HIT_POINT, "video_hit_point", muse::TranslatableString("engraving", "Video hit point") }, { TextStyleType::STAFF, "staff", muse::TranslatableString("engraving", "Staff") }, { TextStyleType::EXPRESSION, "expression", muse::TranslatableString("engraving", "Expression") }, @@ -1861,6 +1862,7 @@ TextStyleType TConv::fromXml(const AsciiStringView& tag, TextStyleType def) { "Repeat Text Right", TextStyleType::REPEAT_RIGHT }, { "Rehearsal Mark", TextStyleType::REHEARSAL_MARK }, { "System", TextStyleType::SYSTEM }, + { "Video Hit Point", TextStyleType::VIDEO_HIT_POINT }, { "Staff", TextStyleType::STAFF }, { "Expression", TextStyleType::EXPRESSION }, diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 86008e7727de1..5adddb360ef9b 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -21,12 +21,22 @@ */ #include "abstractnotationpaintview.h" +#include +#include + #include #include +#include #include #include "actions/actiontypes.h" +#include "engraving/dom/measure.h" #include "engraving/dom/shadownote.h" +#include "engraving/dom/score.h" +#include "engraving/dom/system.h" +#include "engraving/style/styledef.h" +#include "engraving/types/types.h" +#include "project/iprojectvideosettings.h" #include "log.h" using namespace mu; @@ -335,6 +345,12 @@ void AbstractNotationPaintView::onLoadNotation(INotationPtr) scheduleRedraw(); }); + if (m_notation->project() && m_notation->project()->videoSettings()) { + m_notation->project()->videoSettings()->settingsChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + } + m_notation->viewModeChanged().onNotify(this, [this]() { ensureViewportInsideScrollableArea(); }); @@ -395,6 +411,9 @@ void AbstractNotationPaintView::onUnloadNotation(INotationPtr) interaction->dropChanged().disconnect(this); interaction->shadowNoteChanged().disconnect(this); notationPlayback()->loopBoundariesChanged().disconnect(this); + if (m_notation->project() && m_notation->project()->videoSettings()) { + m_notation->project()->videoSettings()->settingsChanged().disconnect(this); + } m_notation->viewModeChanged().disconnect(this); notationAutomation()->automationModeEnabledChanged().disconnect(this); notationAutomation()->automationLinesDataChanged().disconnect(this); @@ -496,6 +515,162 @@ void AbstractNotationPaintView::updateLoopMarkers() } } +void AbstractNotationPaintView::paintVideoHitPoints(QPainter* painter) +{ + if (!m_notation || publishMode() || !m_notation->project() || !m_notation->project()->videoSettings()) { + return; + } + + const project::VideoAttachmentSettings& attachment = m_notation->project()->videoSettings()->attachment(); + if (!attachment.isValid() || attachment.hitPoints.empty()) { + return; + } + + engraving::Score* score = m_notation->elements()->msScore(); + if (!score) { + return; + } + + Color markerColor = score->style().styleV(engraving::Sid::videoHitPointLineColor).value(); + const int transparency = std::clamp(score->style().styleI(engraving::Sid::videoHitPointLineTransparency), 0, 100); + markerColor.setAlpha(static_cast(std::lround(255.0 * (100 - transparency) / 100.0))); + + Qt::PenStyle penStyle = Qt::SolidLine; + switch (score->style().styleV(engraving::Sid::videoHitPointLineStyle).value()) { + case engraving::LineType::DASHED: + penStyle = Qt::DashLine; + break; + case engraving::LineType::DOTTED: + penStyle = Qt::DotLine; + break; + case engraving::LineType::SOLID: + penStyle = Qt::SolidLine; + break; + } + + const qreal lineWidth = std::max(0.08, 0.12 * score->style().spatium()); + QPen markerPen(markerColor.toQColor(), lineWidth, penStyle, Qt::FlatCap); + QFont labelFont(score->style().styleV(engraving::Sid::videoHitPointFontFace).value().toQString()); + qreal labelSize = score->style().styleD(engraving::Sid::videoHitPointFontSize); + if (score->style().styleB(engraving::Sid::videoHitPointFontSpatiumDependent)) { + labelSize *= score->style().spatium() / score->style().defaultSpatium(); + } + labelFont.setPointSizeF(std::max(1.0, labelSize)); + const engraving::FontStyle fontStyle = score->style().styleV(engraving::Sid::videoHitPointFontStyle).value(); + labelFont.setBold(fontStyle & engraving::FontStyle::Bold); + labelFont.setItalic(fontStyle & engraving::FontStyle::Italic); + labelFont.setUnderline(fontStyle & engraving::FontStyle::Underline); + labelFont.setStrikeOut(fontStyle & engraving::FontStyle::Strike); + const QColor labelColor = score->style().styleV(engraving::Sid::videoHitPointColor).value().toQColor(); + const QFontMetrics labelMetrics(labelFont); + const PointF labelOffset = score->style().styleV(engraving::Sid::videoHitPointPosAbove).value(); + const qreal labelOffsetX = labelOffset.x() * score->style().spatium(); + const qreal labelOffsetY = labelOffset.y() * score->style().spatium(); + + painter->save(); + painter->setFont(labelFont); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setRenderHint(QPainter::TextAntialiasing, true); + + for (const project::VideoHitPointSettings& hitPoint : attachment.hitPoints) { + const int scoreRelativeMs = hitPoint.timeMs - attachment.offsetMs; + if (scoreRelativeMs < 0) { + continue; + } + + const double scoreTimeSeconds = static_cast(scoreRelativeMs) / 1000.0; + const midi::tick_t tick = std::max(0, score->utime2utick(scoreTimeSeconds)); + const RectF markerRect = videoHitPointRectByTick(tick); + const RectF viewMarkerRect = fromLogical(markerRect); + if (markerRect.isNull() || viewMarkerRect.right() < 0.0 || viewMarkerRect.left() > width() + || viewMarkerRect.bottom() < 0.0 || viewMarkerRect.top() > height()) { + continue; + } + + const qreal x = markerRect.left(); + const QString label = (hitPoint.label.empty() ? String(u"Hit") : hitPoint.label).toQString(); + const int labelWidth = std::max(qRound(18.0), labelMetrics.horizontalAdvance(label) + qRound(4.0)); + const int labelHeight = std::max(qRound(10.0), labelMetrics.height() + qRound(2.0)); + const QRectF labelRect(x - (labelWidth / 2.0) + labelOffsetX, markerRect.top() - labelHeight - 2.0 + labelOffsetY, + labelWidth, labelHeight); + + painter->setPen(markerPen); + painter->drawLine(QPointF(x, markerRect.top()), QPointF(x, markerRect.bottom())); + painter->setPen(labelColor); + painter->drawText(labelRect, Qt::AlignCenter, label); + } + + painter->restore(); +} + +muse::RectF AbstractNotationPaintView::videoHitPointRectByTick(muse::midi::tick_t _tick) const +{ + if (!m_notation) { + return RectF(); + } + + const engraving::Score* score = m_notation->elements()->msScore(); + if (!score) { + return RectF(); + } + + const Fraction tick = Fraction::fromTicks(_tick); + const Measure* measure = score->tick2measureMM(tick); + if (!measure) { + return RectF(); + } + + const engraving::System* system = measure->system(); + if (!system || !system->page() || system->staves().empty()) { + return RectF(); + } + + qreal x = 0.0; + engraving::Segment* segment = nullptr; + for (segment = measure->first(engraving::SegmentType::ChordRest); segment;) { + const Fraction t1 = segment->tick(); + const qreal x1 = segment->canvasPos().x(); + qreal x2 = 0.0; + Fraction t2; + + engraving::Segment* nextSegment = segment->next(engraving::SegmentType::ChordRest); + while (nextSegment && !nextSegment->visible()) { + nextSegment = nextSegment->next(engraving::SegmentType::ChordRest); + } + + if (nextSegment) { + t2 = nextSegment->tick(); + x2 = nextSegment->canvasPos().x(); + } else { + t2 = measure->endTick(); + const engraving::Segment* endBarLine = measure->findSegment(engraving::SegmentType::EndBarLine, measure->endTick()); + x2 = endBarLine ? endBarLine->canvasPos().x() : measure->canvasPos().x() + measure->width(); + } + + if (tick >= t1 && tick < t2) { + const Fraction dt = t2 - t1; + const qreal dx = x2 - x1; + x = x1 + dx * (tick - t1).ticks() / dt.ticks(); + break; + } + + segment = nextSegment; + } + + if (!segment) { + return RectF(); + } + + const double spatium = score->style().spatium(); + RectF systemRect = system->canvasBoundingRect(); + if (systemRect.isNull()) { + return RectF(); + } + + systemRect.adjust(0.0, -0.35 * spatium, 0.0, 0.35 * spatium); + return RectF { x, systemRect.top(), 0.0, systemRect.height() }; +} + void AbstractNotationPaintView::updateShadowNoteVisibility() { INotationInteractionPtr interaction = notationInteraction(); @@ -721,6 +896,7 @@ void AbstractNotationPaintView::paint(QPainter* qp) m_loopInMarker->paint(painter); m_loopOutMarker->paint(painter); + paintVideoHitPoints(qp); if (notation()->viewMode() == engraving::LayoutMode::LINE) { ContinuousPanel::NotationViewContext nvCtx; diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index 7a618cf183eb4..7042b280ac46d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -277,6 +277,8 @@ protected slots: void onPlaybackCursorRectChanged(); void updateLoopMarkers(); + void paintVideoHitPoints(QPainter* painter); + muse::RectF videoHitPointRectByTick(muse::midi::tick_t tick) const; void updateShadowNoteVisibility(); const Page* pageByPoint(const muse::PointF& point) const; diff --git a/src/notationscene/widgets/editstyle.cpp b/src/notationscene/widgets/editstyle.cpp index 625c8b52bd69b..1ed3e37106980 100644 --- a/src/notationscene/widgets/editstyle.cpp +++ b/src/notationscene/widgets/editstyle.cpp @@ -23,13 +23,22 @@ #include "editstyle.h" #include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include #include +#include #include #include +#include #include "translation.h" #include "types/translatablestring.h" @@ -100,6 +109,7 @@ static const QStringList ALL_PAGE_CODES { "chord-symbols", "fretboard-diagrams", "tablature-styles", + "video-scoring", "text-styles" }; @@ -130,6 +140,7 @@ static const QStringList ALL_TEXT_STYLE_SUBPAGE_CODES { "repeat-text-right", "rehearsal-mark", "system", + "video-hit-point", "staff", "expression", "hairpin", @@ -250,6 +261,57 @@ void EditStyle::classBegin() buttonApplyToAllParts = buttonBox->addButton(muse::qtrc("notation/editstyle", "Apply to all parts"), QDialogButtonBox::ApplyRole); WidgetUtils::setWidgetIcon(buttonTogglePagelist, IconCode::Code::ARROW_RIGHT); + const int videoScoringPageIndex = ALL_PAGE_CODES.indexOf(QStringLiteral("video-scoring")); + pageList->insertItem(videoScoringPageIndex, muse::qtrc("notation/editstyle", "Video Scoring")); + QWidget* pageVideoScoring = new QWidget(pageStack); + QVBoxLayout* videoScoringLayout = new QVBoxLayout(pageVideoScoring); + QGroupBox* videoHitPointGroup = new QGroupBox(muse::qtrc("notation/editstyle", "Video hit points"), pageVideoScoring); + QGridLayout* videoHitPointLayout = new QGridLayout(videoHitPointGroup); + + videoHitPointLineStyle = new QButtonGroup(videoHitPointGroup); + QWidget* videoHitPointLineStyleButtons = new QWidget(videoHitPointGroup); + QHBoxLayout* videoHitPointLineStyleLayout = new QHBoxLayout(videoHitPointLineStyleButtons); + videoHitPointLineStyleLayout->setContentsMargins(0, 0, 0, 0); + videoHitPointLineStyleLayout->setSpacing(4); + + auto addVideoHitPointLineStyleButton = [this, videoHitPointLineStyleButtons, videoHitPointLineStyleLayout](const QString& text, LineType lineType) { + QToolButton* button = new QToolButton(videoHitPointLineStyleButtons); + button->setCheckable(true); + button->setText(text); + button->setMinimumWidth(78); + button->setAutoRaise(false); + videoHitPointLineStyle->addButton(button, int(lineType)); + videoHitPointLineStyleLayout->addWidget(button); + }; + + addVideoHitPointLineStyleButton(QStringLiteral("_____"), LineType::SOLID); + addVideoHitPointLineStyleButton(QStringLiteral("- - -"), LineType::DASHED); + addVideoHitPointLineStyleButton(QStringLiteral(". . ."), LineType::DOTTED); + videoHitPointLineTransparency = new QSpinBox(videoHitPointGroup); + videoHitPointLineTransparency->setKeyboardTracking(false); + videoHitPointLineTransparency->setRange(0, 100); + videoHitPointLineTransparency->setSuffix(muse::qtrc("global", "%")); + + videoHitPointLineColor = new Awl::ColorLabel(videoHitPointGroup); + + resetVideoHitPointLineStyle = new QToolButton(videoHitPointGroup); + resetVideoHitPointLineTransparency = new QToolButton(videoHitPointGroup); + resetVideoHitPointLineColor = new QToolButton(videoHitPointGroup); + + videoHitPointLayout->addWidget(new QLabel(muse::qtrc("notation/editstyle", "Line style:"), videoHitPointGroup), 0, 0); + videoHitPointLayout->addWidget(videoHitPointLineStyleButtons, 0, 1); + videoHitPointLayout->addWidget(resetVideoHitPointLineStyle, 0, 2); + videoHitPointLayout->addWidget(new QLabel(muse::qtrc("notation/editstyle", "Transparency:"), videoHitPointGroup), 1, 0); + videoHitPointLayout->addWidget(videoHitPointLineTransparency, 1, 1); + videoHitPointLayout->addWidget(resetVideoHitPointLineTransparency, 1, 2); + videoHitPointLayout->addWidget(new QLabel(muse::qtrc("notation/editstyle", "Color:"), videoHitPointGroup), 2, 0); + videoHitPointLayout->addWidget(videoHitPointLineColor, 2, 1); + videoHitPointLayout->addWidget(resetVideoHitPointLineColor, 2, 2); + videoHitPointLayout->setColumnStretch(1, 1); + videoScoringLayout->addWidget(videoHitPointGroup); + videoScoringLayout->addStretch(1); + pageStack->insertWidget(videoScoringPageIndex, pageVideoScoring); + // ==================================================== // Button Groups // ==================================================== @@ -624,6 +686,8 @@ void EditStyle::classBegin() { StyleId::musicalTextFont, false, musicalTextFont, 0 }, { StyleId::autoplaceHairpinDynamicsDistance, false, autoplaceHairpinDynamicsDistance, resetAutoplaceHairpinDynamicsDistance }, + { StyleId::videoHitPointLineStyle, false, videoHitPointLineStyle, resetVideoHitPointLineStyle }, + { StyleId::videoHitPointLineTransparency, false, videoHitPointLineTransparency, resetVideoHitPointLineTransparency }, { StyleId::dynamicsPosAbove, false, dynamicsPosAbove, resetDynamicsPosAbove }, { StyleId::dynamicsPosBelow, false, dynamicsPosBelow, resetDynamicsPosBelow }, @@ -1113,6 +1177,16 @@ void EditStyle::classBegin() setSignalMapper->setMapping(sw.widget, static_cast(sw.idx)); } + WidgetUtils::setWidgetIcon(resetVideoHitPointLineColor, IconCode::Code::UNDO); + connect(videoHitPointLineColor, &Awl::ColorLabel::colorChanged, this, [this](const QColor& color) { + setStyleValue(StyleId::videoHitPointLineColor, PropertyValue::fromValue(Color(color))); + resetVideoHitPointLineColor->setEnabled(!hasDefaultStyleValue(StyleId::videoHitPointLineColor)); + }); + connect(resetVideoHitPointLineColor, &QToolButton::clicked, this, [this]() { + setStyleValue(StyleId::videoHitPointLineColor, defaultStyleValue(StyleId::videoHitPointLineColor)); + setValues(); + }); + connect(setSignalMapper, &QSignalMapper::mappedInt, this, &EditStyle::valueChanged); connect(resetSignalMapper, &QSignalMapper::mappedInt, this, &EditStyle::resetStyleValue); @@ -1604,7 +1678,7 @@ void EditStyle::goToTextStylePage(const QString& code) int index = ALL_PAGE_CODES.indexOf("text-styles"); int subIndex = ALL_TEXT_STYLE_SUBPAGE_CODES.indexOf(code); - IF_ASSERT_FAILED(index >= 0) { + IF_ASSERT_FAILED(index >= 0 && subIndex >= 0 && subIndex < textStyles->count()) { return; } @@ -1620,6 +1694,10 @@ void EditStyle::goToTextStylePage(const QString& code) void EditStyle::goToTextStylePage(int index) { + if (index < 0 || index >= textStyles->count() || index >= ALL_TEXT_STYLE_SUBPAGE_CODES.size()) { + return; + } + pageList->setCurrentRow(ALL_PAGE_CODES.indexOf("text-styles")); m_currentPageCode = "text-styles"; @@ -2027,6 +2105,11 @@ void EditStyle::setValues() } } + videoHitPointLineColor->blockSignals(true); + videoHitPointLineColor->setColor(styleValue(StyleId::videoHitPointLineColor).value().toQColor()); + videoHitPointLineColor->blockSignals(false); + resetVideoHitPointLineColor->setEnabled(!hasDefaultStyleValue(StyleId::videoHitPointLineColor)); + textStyleChanged(textStyles->currentRow()); emit dynamicsAndHairpinPos->currentIndexChanged(dynamicsAndHairpinPos->currentIndex()); @@ -2412,6 +2495,10 @@ void EditStyle::resetStyleValue(int i) void EditStyle::textStyleChanged(int row) { + if (row < 0 || row >= textStyles->count()) { + return; + } + TextStyleType tid = TextStyleType(textStyles->item(row)->data(Qt::UserRole).toInt()); const TextStyle* ts = textStyle(tid); diff --git a/src/notationscene/widgets/editstyle.h b/src/notationscene/widgets/editstyle.h index 5e073f0f06133..eeae9c6f0bb6d 100644 --- a/src/notationscene/widgets/editstyle.h +++ b/src/notationscene/widgets/editstyle.h @@ -38,6 +38,14 @@ #include "inotationsceneconfiguration.h" class QQuickView; +class QComboBox; +class QButtonGroup; +class QSpinBox; +class QToolButton; + +namespace Awl { +class ColorLabel; +} namespace mu::notation { class EditStyle : public muse::ui::WidgetDialog, private Ui::EditStyleBase @@ -116,6 +124,12 @@ public slots: std::vector verticalPlacementComboBoxes; QPushButton* buttonApplyToAllParts = nullptr; + QButtonGroup* videoHitPointLineStyle = nullptr; + QSpinBox* videoHitPointLineTransparency = nullptr; + Awl::ColorLabel* videoHitPointLineColor = nullptr; + QToolButton* resetVideoHitPointLineStyle = nullptr; + QToolButton* resetVideoHitPointLineTransparency = nullptr; + QToolButton* resetVideoHitPointLineColor = nullptr; void unhandledType(const StyleWidget); PropertyValue getValue(StyleId idx); diff --git a/src/notationscene/widgets/editstyleutils.cpp b/src/notationscene/widgets/editstyleutils.cpp index a6570572e8ad1..4b4b830ef2a14 100644 --- a/src/notationscene/widgets/editstyleutils.cpp +++ b/src/notationscene/widgets/editstyleutils.cpp @@ -293,6 +293,9 @@ QString EditStyleUtils::subPageCodeForElement(const EngravingItem* element) case TextStyleType::SYSTEM: return "system"; + case TextStyleType::VIDEO_HIT_POINT: + return "video-hit-point"; + case TextStyleType::STAFF: return "staff"; diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml index 8a7934fa6dc80..406621a5b7e90 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanel.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml @@ -40,6 +40,11 @@ Item { readonly property int contentMargin: 8 readonly property bool compactMode: width < 620 readonly property color hitPointColor: "#3B94E5" + readonly property real previewHeightRatio: 0.42 + readonly property int controlsScrollbarReserve: 12 + readonly property int minimumControlsHeight: 168 + readonly property int timelineFrameRate: Math.max(1, Math.round(videoModel.frameRate)) + readonly property int timelineFrameCount: videoModel.hasVideo && video.duration > 0 ? Math.floor((video.duration / 1000) * root.timelineFrameRate) + 1 : 0 clip: true @@ -60,7 +65,163 @@ Item { function clearAttachedVideo() { video.stop() video.source = "" - videoModel.clearVideo() + Qt.callLater(videoModel.clearVideo) + } + + function detectedFrameRate() { + try { + if (!video.metaData) { + return 0 + } + + var candidates = [ + video.metaData.videoFrameRate, + video.metaData.VideoFrameRate, + video.metaData.frameRate, + video.metaData.FrameRate + ] + + if (typeof MediaMetaData !== "undefined" && video.metaData.value) { + candidates.push(video.metaData.value(MediaMetaData.VideoFrameRate)) + } + + for (var i = 0; i < candidates.length; ++i) { + var rate = Number(candidates[i]) + if (!isNaN(rate) && rate > 0) { + return Math.round(rate * 1000) / 1000 + } + } + } catch (error) { + return 0 + } + + return 0 + } + + function videoAspectRatio() { + try { + if (!video.metaData) { + return 16 / 9 + } + + var sizeCandidates = [ + video.metaData.resolution, + video.metaData.Resolution, + video.metaData.videoResolution, + video.metaData.VideoResolution + ] + + if (typeof MediaMetaData !== "undefined" && video.metaData.value) { + sizeCandidates.push(video.metaData.value(MediaMetaData.Resolution)) + sizeCandidates.push(video.metaData.value(MediaMetaData.VideoResolution)) + } + + for (var i = 0; i < sizeCandidates.length; ++i) { + var size = sizeCandidates[i] + if (size && size.width > 0 && size.height > 0) { + return size.width / size.height + } + } + + var widthCandidates = [ + video.metaData.videoWidth, + video.metaData.VideoWidth, + video.metaData.width, + video.metaData.Width + ] + var heightCandidates = [ + video.metaData.videoHeight, + video.metaData.VideoHeight, + video.metaData.height, + video.metaData.Height + ] + + for (var w = 0; w < widthCandidates.length; ++w) { + for (var h = 0; h < heightCandidates.length; ++h) { + var videoWidth = Number(widthCandidates[w]) + var videoHeight = Number(heightCandidates[h]) + if (!isNaN(videoWidth) && !isNaN(videoHeight) && videoWidth > 0 && videoHeight > 0) { + return videoWidth / videoHeight + } + } + } + } catch (error) { + return 16 / 9 + } + + return 16 / 9 + } + + function autodetectFrameRate() { + var rate = detectedFrameRate() + if (rate > 0) { + videoModel.frameRate = rate + } + } + + function timelineTickHeight(frameIndex) { + var oneSecond = root.timelineFrameRate + if (frameIndex % (oneSecond * 60) === 0) { + return 14 + } + + if (frameIndex % (oneSecond * 30) === 0) { + return 13 + } + + if (frameIndex % (oneSecond * 15) === 0) { + return 12 + } + + if (frameIndex % (oneSecond * 10) === 0) { + return 10 + } + + if (frameIndex % (oneSecond * 5) === 0) { + return 8 + } + + if (frameIndex % oneSecond === 0) { + return 6 + } + + return 2 + } + + function timelineTickWidth(frameIndex) { + var oneSecond = root.timelineFrameRate + if (frameIndex % (oneSecond * 30) === 0) { + return 2 + } + + if (frameIndex % oneSecond === 0) { + return 1.5 + } + + return 1 + } + + function snappedTimelinePositionMs(positionMs) { + var frameDurationMs = 1000 / Math.max(root.timelineFrameRate, 1) + return Math.max(0, Math.min(video.duration, Math.round(positionMs / frameDurationMs) * frameDurationMs)) + } + + function timelinePositionForX(x, timelineWidth) { + if (timelineWidth <= 0 || video.duration <= 0) { + return 0 + } + + return snappedTimelinePositionMs((Math.max(0, Math.min(timelineWidth, x)) / timelineWidth) * video.duration) + } + + function timelineLabel(seconds) { + if (seconds < 60) { + return seconds + qsTrc("playback", "s") + } + + var minutes = Math.floor(seconds / 60) + var remainingSeconds = seconds % 60 + return minutes + ":" + (remainingSeconds < 10 ? "0" : "") + remainingSeconds } function syncVideoToScore(forceSeek) { @@ -106,21 +267,19 @@ Item { } } - GridLayout { + Item { + id: contentItem + anchors.fill: parent anchors.margins: root.contentMargin - columns: 1 - rowSpacing: 8 - columnSpacing: 12 - Rectangle { - Layout.preferredWidth: -1 - Layout.maximumWidth: 16777215 - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredHeight: root.compactMode ? 180 : 260 - Layout.minimumHeight: 96 + id: previewSlot + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: Math.round(Math.max(96, Math.min(parent.height * root.previewHeightRatio, parent.height - root.minimumControlsHeight))) radius: 4 color: "#111111" @@ -128,50 +287,62 @@ Item { border.color: ui.theme.strokeColor clip: true - Video { - id: video + Item { + id: videoFrame - anchors.fill: parent - anchors.margins: 1 - source: videoModel.videoUrl - muted: videoModel.muted - volume: videoModel.volumePercent / 100 - fillMode: VideoOutput.PreserveAspectFit - visible: videoModel.hasVideo + readonly property real aspectRatio: Math.max(0.1, root.videoAspectRatio()) + readonly property real availableWidth: Math.max(0, previewSlot.width - 2) + readonly property real availableHeight: Math.max(0, previewSlot.height - 2) + readonly property bool limitedByHeight: availableHeight > 0 && availableWidth / availableHeight > aspectRatio - onSourceChanged: { - stop() - } + anchors.centerIn: parent + width: Math.round(limitedByHeight ? availableHeight * aspectRatio : availableWidth) + height: Math.round(limitedByHeight ? availableHeight : availableWidth / aspectRatio) - onDurationChanged: { - root.syncVideoToScore(true) - } - } + Video { + id: video - ColumnLayout { - anchors.centerIn: parent - width: Math.min(parent.width - 24, 200) - spacing: 6 - visible: !videoModel.hasVideo - - StyledIconLabel { - Layout.alignment: Qt.AlignHCenter - iconCode: IconCode.PLAY - font.pixelSize: 24 - opacity: 0.45 + anchors.fill: parent + source: videoModel.videoUrl + muted: videoModel.muted + volume: videoModel.volumePercent / 100 + fillMode: VideoOutput.PreserveAspectFit + visible: videoModel.hasVideo + + onSourceChanged: { + stop() + } + + onDurationChanged: { + root.syncVideoToScore(true) + } } - StyledTextLabel { - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - text: qsTrc("playback", "No video attached") - maximumLineCount: 2 - wrapMode: Text.WordWrap + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width - 24, 200) + spacing: 6 + visible: !videoModel.hasVideo + + StyledIconLabel { + Layout.alignment: Qt.AlignHCenter + iconCode: IconCode.PLAY + font.pixelSize: 24 + opacity: 0.45 + } + + StyledTextLabel { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: qsTrc("playback", "No video attached") + maximumLineCount: 2 + wrapMode: Text.WordWrap + } } } FlatButton { - anchors.centerIn: parent + anchors.centerIn: videoFrame width: 44 height: 44 visible: videoModel.hasVideo && video.playbackState !== MediaPlayer.PlayingState @@ -188,77 +359,31 @@ Item { } } - Item { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 36 - visible: videoModel.hasVideo && video.duration > 0 && videoModel.hitPoints.length > 0 - - Rectangle { - anchors.fill: parent - color: "#99000000" - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 2 - color: "#66FFFFFF" - } - - Repeater { - model: videoModel.hitPoints - - Item { - required property var modelData + } - x: Math.max(0, Math.min(parent.width - width, (modelData.timeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) - width: Math.max(20, hitLabel.implicitWidth + 8) - height: parent.height + StyledFlickable { + id: controlsFlickable - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.bottom: hitLabel.top - width: 2 - color: root.hitPointColor - } + anchors.left: parent.left + anchors.right: parent.right + anchors.top: previewSlot.bottom + anchors.topMargin: 8 + anchors.bottom: parent.bottom - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - width: 8 - height: 8 - radius: 4 - color: root.hitPointColor - border.width: 1 - border.color: "white" - } - - StyledTextLabel { - id: hitLabel + contentHeight: controlsColumn.implicitHeight + interactive: contentHeight > height + clip: true + boundsBehavior: Flickable.StopAtBounds + readonly property bool overflowing: contentHeight > height + 1 - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: 2 - text: parent.modelData.label - maximumLineCount: 1 - font.pixelSize: 10 - color: "white" - } - } - } + ScrollBar.vertical: StyledScrollBar { + policy: controlsFlickable.overflowing ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded + padding: 0 } - } - ColumnLayout { id: controlsColumn - Layout.fillWidth: true - Layout.fillHeight: false - Layout.minimumWidth: 0 + width: Math.max(0, controlsFlickable.width - (controlsFlickable.overflowing ? root.controlsScrollbarReserve : 0)) spacing: 8 RowLayout { @@ -285,14 +410,15 @@ Item { Item { Layout.fillWidth: true - Layout.preferredHeight: 30 + Layout.preferredHeight: videoModel.hitPoints.length > 0 ? 70 : 58 StyledSlider { id: positionSlider anchors.left: parent.left anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.top: parent.top + anchors.topMargin: videoModel.hitPoints.length > 0 ? 17 : 8 from: 0 to: Math.max(video.duration, 1) stepSize: 100 @@ -306,19 +432,127 @@ Item { } } + Canvas { + id: frameTickCanvas + + anchors.left: parent.left + anchors.right: parent.right + y: 41 + height: 28 + visible: videoModel.hasVideo && video.duration > 0 + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + + var tickCount = root.timelineFrameCount + if (tickCount <= 0) { + return + } + + var primaryColor = ui.theme.fontPrimaryColor.toString() + var secondaryColor = ui.theme.strokeColor.toString() + for (var i = 0; i < tickCount; ++i) { + var tickWidth = root.timelineTickWidth(i) + var tickHeight = root.timelineTickHeight(i) + var x = Math.max(0, Math.min(width - tickWidth, (i / Math.max(tickCount - 1, 1)) * width - (tickWidth / 2))) + ctx.globalAlpha = i % root.timelineFrameRate === 0 ? 0.62 : 0.24 + ctx.fillStyle = i % root.timelineFrameRate === 0 ? primaryColor : secondaryColor + ctx.fillRect(x, 0, tickWidth, tickHeight) + } + + var durationSeconds = Math.floor(video.duration / 1000) + ctx.font = "10px sans-serif" + ctx.textAlign = "center" + ctx.textBaseline = "bottom" + ctx.fillStyle = primaryColor + ctx.globalAlpha = 0.74 + for (var second = 0; second <= durationSeconds; second += 5) { + var labelX = (second * 1000 / Math.max(video.duration, 1)) * width + ctx.fillText(root.timelineLabel(second), labelX, height) + } + ctx.globalAlpha = 1 + } + + Connections { + target: videoModel + function onVideoSettingsChanged() { + frameTickCanvas.requestPaint() + } + } + + Connections { + target: video + function onDurationChanged() { + frameTickCanvas.requestPaint() + } + } + + onWidthChanged: requestPaint() + onVisibleChanged: requestPaint() + } + Repeater { model: videoModel.hitPoints Rectangle { + id: hitPointMarker + required property var modelData + required property int index + property bool dragging: false + property real dragTimeMs: modelData.timeMs + readonly property real displayTimeMs: dragging ? dragTimeMs : modelData.timeMs - x: Math.max(0, Math.min(parent.width - width, (modelData.timeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) - y: 2 + x: Math.max(0, Math.min(parent.width - width, (displayTimeMs / Math.max(video.duration, 1)) * parent.width - (width / 2))) + y: 13 width: 3 - height: parent.height - 4 + height: 34 radius: 1 visible: videoModel.hasVideo && video.duration > 0 color: root.hitPointColor + + StyledTextLabel { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.top + anchors.bottomMargin: 1 + text: parent.modelData.label + maximumLineCount: 1 + font.pixelSize: 10 + color: root.hitPointColor + } + + MouseArea { + anchors.fill: parent + anchors.leftMargin: -8 + anchors.rightMargin: -8 + cursorShape: Qt.SizeHorCursor + preventStealing: true + + onPressed: function(mouse) { + var mapped = mapToItem(positionSlider, mouse.x, mouse.y) + hitPointMarker.dragging = true + hitPointMarker.dragTimeMs = root.timelinePositionForX(mapped.x, positionSlider.width) + } + + onPositionChanged: function(mouse) { + if (!pressed) { + return + } + + var mapped = mapToItem(positionSlider, mouse.x, mouse.y) + hitPointMarker.dragTimeMs = root.timelinePositionForX(mapped.x, positionSlider.width) + } + + onReleased: { + videoModel.setHitPointTimeMs(hitPointMarker.index, hitPointMarker.dragTimeMs) + hitPointMarker.dragging = false + } + + onCanceled: { + hitPointMarker.dragging = false + } + } } } } @@ -427,11 +661,22 @@ Item { } } + FlatButton { + text: qsTrc("playback", "Detect FPS") + enabled: videoModel.hasVideo && root.detectedFrameRate() > 0 + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 10 + + onClicked: { + root.autodetectFrameRate() + } + } + FlatButton { text: qsTrc("playback", "Add hit point") enabled: videoModel.hasVideo navigation.panel: navigationPanel - navigation.order: root.contentNavigationPanelOrderStart + 10 + navigation.order: root.contentNavigationPanelOrderStart + 11 onClicked: { videoModel.addHitPoint(video.position) @@ -443,25 +688,57 @@ Item { id: hitPointsFlickable Layout.fillWidth: true - Layout.preferredHeight: Math.min(hitPointsColumn.implicitHeight, 132) + Layout.preferredHeight: Math.min(hitPointsColumn.implicitHeight, 148) Layout.maximumHeight: 132 visible: videoModel.hitPoints.length > 0 contentHeight: hitPointsColumn.implicitHeight interactive: contentHeight > height clip: true + boundsBehavior: Flickable.StopAtBounds + readonly property bool overflowing: contentHeight > height + 1 ScrollBar.vertical: StyledScrollBar { - policy: hitPointsFlickable.contentHeight > hitPointsFlickable.height ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded + policy: hitPointsFlickable.overflowing ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded padding: 0 } ColumnLayout { id: hitPointsColumn - width: hitPointsFlickable.width - (hitPointsFlickable.contentHeight > hitPointsFlickable.height ? 12 : 0) + width: Math.max(0, hitPointsFlickable.width - (hitPointsFlickable.overflowing ? root.controlsScrollbarReserve : 0)) spacing: 4 + RowLayout { + width: hitPointsColumn.width + spacing: 8 + + StyledTextLabel { + Layout.preferredWidth: 92 + text: qsTrc("playback", "Timecode") + font: ui.theme.bodyBoldFont + maximumLineCount: 1 + } + + StyledTextLabel { + Layout.preferredWidth: 68 + text: qsTrc("playback", "Measure") + font: ui.theme.bodyBoldFont + maximumLineCount: 1 + } + + StyledTextLabel { + Layout.fillWidth: true + text: qsTrc("playback", "Name") + font: ui.theme.bodyBoldFont + maximumLineCount: 1 + } + + Item { + Layout.preferredWidth: 32 + } + } + Repeater { model: videoModel.hitPoints @@ -472,13 +749,54 @@ Item { required property int index property bool editingLabel: false + property bool editingTimecode: false width: hitPointsColumn.width spacing: 8 - StyledTextLabel { + Item { Layout.preferredWidth: 92 - text: hitPointDelegate.modelData.timecode + Layout.preferredHeight: 28 + + StyledTextLabel { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: hitPointDelegate.modelData.timecode + maximumLineCount: 1 + visible: !hitPointDelegate.editingTimecode + + MouseArea { + anchors.fill: parent + onClicked: { + hitPointDelegate.editingTimecode = true + timecodeEditor.forceActiveFocus() + } + } + } + + TextInputField { + id: timecodeEditor + + anchors.fill: parent + currentText: hitPointDelegate.modelData.timecode + visible: hitPointDelegate.editingTimecode + navigation.panel: navigationPanel + navigation.order: root.contentNavigationPanelOrderStart + 12 + hitPointDelegate.index + + onTextEditingFinished: function(newTextValue) { + videoModel.setHitPointTimecode(hitPointDelegate.index, newTextValue) + hitPointDelegate.editingTimecode = false + } + + Keys.onEscapePressed: { + hitPointDelegate.editingTimecode = false + } + } + } + + StyledTextLabel { + Layout.preferredWidth: 68 + text: hitPointDelegate.modelData.musicalPosition maximumLineCount: 1 } @@ -489,7 +807,7 @@ Item { StyledTextLabel { anchors.fill: parent verticalAlignment: Text.AlignVCenter - text: hitPointDelegate.modelData.label + " " + hitPointDelegate.modelData.musicalPosition + text: hitPointDelegate.modelData.label maximumLineCount: 1 visible: !hitPointDelegate.editingLabel @@ -559,6 +877,7 @@ Item { } } } + } } NavigationPanel { diff --git a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp index a61e3d416b3d1..d501a4f7609cf 100644 --- a/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/mixerpanelmodel.cpp @@ -277,7 +277,9 @@ void MixerPanelModel::clear() TRACEFUNC; m_masterChannelItem = nullptr; - qDeleteAll(m_mixerChannelList); + for (MixerChannelItem* item : m_mixerChannelList) { + item->deleteLater(); + } m_mixerChannelList.clear(); } diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index 4467f27d18eb5..ceddbb1302c90 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -25,6 +25,8 @@ #include #include +#include + #include "engraving/dom/masterscore.h" #include "engraving/dom/measure.h" #include "engraving/dom/score.h" @@ -117,6 +119,36 @@ void VideoPanelModel::renameHitPoint(int index, const QString& label) updateAttachment(updated); } +void VideoPanelModel::setHitPointTimecode(int index, const QString& timecode) +{ + const int positionMs = parseTimecodeToMs(timecode); + if (positionMs < 0) { + return; + } + + setHitPointTimeMs(index, positionMs); +} + +void VideoPanelModel::setHitPointTimeMs(int index, int videoPositionMs) +{ + VideoAttachmentSettings updated = attachment(); + if (!updated.isValid() || index < 0 || index >= static_cast(updated.hitPoints.size())) { + return; + } + + const int positionMs = std::max(0, videoPositionMs); + if (updated.hitPoints.at(index).timeMs == positionMs) { + return; + } + + updated.hitPoints[index].timeMs = positionMs; + std::sort(updated.hitPoints.begin(), updated.hitPoints.end(), [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { + return a.timeMs < b.timeMs; + }); + + updateAttachment(updated); +} + QString VideoPanelModel::formatTimecode(int videoPositionMs) const { videoPositionMs = std::max(0, videoPositionMs); @@ -137,6 +169,41 @@ QString VideoPanelModel::formatTimecode(int videoPositionMs) const .arg(frames, 2, 10, QLatin1Char('0')); } +int VideoPanelModel::parseTimecodeToMs(const QString& timecode) const +{ + const QStringList parts = timecode.trimmed().split(QLatin1Char(':')); + if (parts.size() != 4) { + return -1; + } + + bool ok = false; + const int hours = parts.at(0).toInt(&ok); + if (!ok || hours < 0) { + return -1; + } + + const int minutes = parts.at(1).toInt(&ok); + if (!ok || minutes < 0 || minutes > 59) { + return -1; + } + + const int seconds = parts.at(2).toInt(&ok); + if (!ok || seconds < 0 || seconds > 59) { + return -1; + } + + const double framesPerSecond = std::clamp(frameRate(), 1.0, 240.0); + const int roundedFrameRate = std::max(1, static_cast(std::lround(framesPerSecond))); + const int frames = parts.at(3).toInt(&ok); + if (!ok || frames < 0 || frames >= roundedFrameRate) { + return -1; + } + + const qint64 totalSeconds = static_cast(hours) * 3600 + minutes * 60 + seconds; + const qint64 totalFrames = totalSeconds * roundedFrameRate + frames; + return static_cast(std::floor((static_cast(totalFrames) * 1000.0 / roundedFrameRate) + 0.5)); +} + bool VideoPanelModel::hasVideo() const { return attachment().isValid(); diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 693cf736309ce..4b8ae3405274e 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -66,6 +66,8 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a Q_INVOKABLE void addHitPoint(int videoPositionMs); Q_INVOKABLE void removeHitPoint(int index); Q_INVOKABLE void renameHitPoint(int index, const QString& label); + Q_INVOKABLE void setHitPointTimeMs(int index, int videoPositionMs); + Q_INVOKABLE void setHitPointTimecode(int index, const QString& timecode); Q_INVOKABLE QString formatTimecode(int videoPositionMs) const; bool hasVideo() const; @@ -109,6 +111,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a void updateAttachment(const project::VideoAttachmentSettings& attachment); QVariantMap hitPointToMap(const project::VideoHitPointSettings& hitPoint) const; QString musicalPositionText(int videoPositionMs) const; + int parseTimecodeToMs(const QString& timecode) const; void listenCurrentProject(); void listenPlaybackController(); From 8a606183036574ff5c9b56ad774954ab0ea79e6b Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:45:29 -0400 Subject: [PATCH 24/28] Fix video hit point style CI regressions --- src/engraving/style/textstyle.cpp | 12 +++++++++--- .../NotationScene/abstractnotationpaintview.cpp | 5 +++-- src/notationscene/widgets/editstyle.cpp | 3 ++- src/project/internal/projectvideosettings.cpp | 4 ++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/engraving/style/textstyle.cpp b/src/engraving/style/textstyle.cpp index 097724c382a7e..a178cbba2cd56 100644 --- a/src/engraving/style/textstyle.cpp +++ b/src/engraving/style/textstyle.cpp @@ -790,8 +790,6 @@ const TextStyle videoHitPointTextStyle { { TextStylePropertyType::FrameRound, Sid::videoHitPointFrameRound, Pid::FRAME_ROUND }, { TextStylePropertyType::FrameBorderColor, Sid::videoHitPointFrameFgColor, Pid::FRAME_FG_COLOR }, { TextStylePropertyType::FrameFillColor, Sid::videoHitPointFrameBgColor, Pid::FRAME_BG_COLOR }, - { TextStylePropertyType::MusicalSymbolsScale, Sid::dummyMusicalSymbolsScale, Pid::MUSICAL_SYMBOLS_SCALE }, - { TextStylePropertyType::MusicalSymbolsSize, Sid::defaultMusicalSymbolSize, Pid::MUSIC_SYMBOL_SIZE }, { TextStylePropertyType::Position, Sid::videoHitPointPosition, Pid::POSITION }, } }, { Sid::videoHitPointPosAbove, Sid::videoHitPointPosBelow } @@ -1795,7 +1793,6 @@ static const std::vector _primaryTextStyles = { TextStyleType::REPEAT_RIGHT, TextStyleType::REHEARSAL_MARK, TextStyleType::SYSTEM, - TextStyleType::VIDEO_HIT_POINT, TextStyleType::STAFF, TextStyleType::EXPRESSION, TextStyleType::DYNAMICS, @@ -1830,6 +1827,9 @@ const std::vector& allTextStyles() if (_allTextStyles.empty()) { _allTextStyles.reserve(int(TextStyleType::TEXT_TYPES)); for (int t = int(TextStyleType::DEFAULT) + 1; t < int(TextStyleType::TEXT_TYPES); ++t) { + if (TextStyleType(t) == TextStyleType::VIDEO_HIT_POINT) { + continue; + } _allTextStyles.push_back(TextStyleType(t)); } } @@ -1852,6 +1852,12 @@ const std::vector& editableTextStyles() if (_editableTextStyles.empty()) { _editableTextStyles = allTextStyles(); muse::remove(_editableTextStyles, TextStyleType::DYNAMICS); + for (auto it = _editableTextStyles.begin(); it != _editableTextStyles.end(); ++it) { + if (*it == TextStyleType::SYSTEM) { + _editableTextStyles.insert(++it, TextStyleType::VIDEO_HIT_POINT); + break; + } + } } return _editableTextStyles; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 5adddb360ef9b..f885d9db7ae5e 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -591,8 +591,9 @@ void AbstractNotationPaintView::paintVideoHitPoints(QPainter* painter) const QString label = (hitPoint.label.empty() ? String(u"Hit") : hitPoint.label).toQString(); const int labelWidth = std::max(qRound(18.0), labelMetrics.horizontalAdvance(label) + qRound(4.0)); const int labelHeight = std::max(qRound(10.0), labelMetrics.height() + qRound(2.0)); - const QRectF labelRect(x - (labelWidth / 2.0) + labelOffsetX, markerRect.top() - labelHeight - 2.0 + labelOffsetY, - labelWidth, labelHeight); + const QRectF labelRect(x - (labelWidth / 2.0) + labelOffsetX, + markerRect.top() - labelHeight - 2.0 + labelOffsetY, + labelWidth, labelHeight); painter->setPen(markerPen); painter->drawLine(QPointF(x, markerRect.top()), QPointF(x, markerRect.bottom())); diff --git a/src/notationscene/widgets/editstyle.cpp b/src/notationscene/widgets/editstyle.cpp index 1ed3e37106980..d8ce324c473d1 100644 --- a/src/notationscene/widgets/editstyle.cpp +++ b/src/notationscene/widgets/editstyle.cpp @@ -274,7 +274,8 @@ void EditStyle::classBegin() videoHitPointLineStyleLayout->setContentsMargins(0, 0, 0, 0); videoHitPointLineStyleLayout->setSpacing(4); - auto addVideoHitPointLineStyleButton = [this, videoHitPointLineStyleButtons, videoHitPointLineStyleLayout](const QString& text, LineType lineType) { + auto addVideoHitPointLineStyleButton + = [this, videoHitPointLineStyleButtons, videoHitPointLineStyleLayout](const QString& text, LineType lineType) { QToolButton* button = new QToolButton(videoHitPointLineStyleButtons); button->setCheckable(true); button->setText(text); diff --git a/src/project/internal/projectvideosettings.cpp b/src/project/internal/projectvideosettings.cpp index 260478a59f5d0..b0b3da90cd0c5 100644 --- a/src/project/internal/projectvideosettings.cpp +++ b/src/project/internal/projectvideosettings.cpp @@ -42,8 +42,8 @@ static void normalizeHitPoints(VideoAttachmentSettings& attachment) std::stable_sort( attachment.hitPoints.begin(), attachment.hitPoints.end(), [](const VideoHitPointSettings& a, const VideoHitPointSettings& b) { - return a.timeMs < b.timeMs; - }); + return a.timeMs < b.timeMs; + }); } const VideoAttachmentSettings& ProjectVideoSettings::attachment() const From 3148bd51ff275e80403b9fb6e7389093fcb54403 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:08:36 -0400 Subject: [PATCH 25/28] Document video hit point text style handling --- src/engraving/style/textstyle.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/engraving/style/textstyle.cpp b/src/engraving/style/textstyle.cpp index a178cbba2cd56..8409f6f94d8dc 100644 --- a/src/engraving/style/textstyle.cpp +++ b/src/engraving/style/textstyle.cpp @@ -1827,6 +1827,8 @@ const std::vector& allTextStyles() if (_allTextStyles.empty()) { _allTextStyles.reserve(int(TextStyleType::TEXT_TYPES)); for (int t = int(TextStyleType::DEFAULT) + 1; t < int(TextStyleType::TEXT_TYPES); ++t) { + // Video hit point text is editor-only and rendered by the notation view; + // keep it out of broad score serialization/import defaults. if (TextStyleType(t) == TextStyleType::VIDEO_HIT_POINT) { continue; } @@ -1852,6 +1854,8 @@ const std::vector& editableTextStyles() if (_editableTextStyles.empty()) { _editableTextStyles = allTextStyles(); muse::remove(_editableTextStyles, TextStyleType::DYNAMICS); + // Reinsert video hit point text for the Style dialog without making it a + // globally serialized text style. for (auto it = _editableTextStyles.begin(); it != _editableTextStyles.end(); ++it) { if (*it == TextStyleType::SYSTEM) { _editableTextStyles.insert(++it, TextStyleType::VIDEO_HIT_POINT); From e3480dd5f252011db1586c7e4f35cbbfa0b27cb3 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:55:43 -0400 Subject: [PATCH 26/28] Lazy load video panel multimedia --- .../AppShell/NotationPage/NotationPage.qml | 3 +- .../qml/MuseScore/Playback/CMakeLists.txt | 9 +-- .../MuseScore/Playback/VideoPanelLoader.qml | 58 +++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml diff --git a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml index 7093741e5bd6b..a548910bca2c6 100644 --- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml +++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml @@ -588,10 +588,11 @@ DockPage { navigationSection: root.navigationPanelSec(videoPanel.location) - VideoPanel { + VideoPanelLoader { anchors.fill: parent navigationSection: videoPanel.navigationSection contentNavigationPanelOrderStart: videoPanel.contentNavigationPanelOrderStart + loadPanel: videoPanel.visible } } ] diff --git a/src/playback/qml/MuseScore/Playback/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt index 939dc164ad175..0ce0321237c7e 100644 --- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt +++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt @@ -26,10 +26,6 @@ set(PLAYBACK_QML_IMPORTS TARGET notationscene_qml ) -if (Qt6Multimedia_FOUND) - list(APPEND PLAYBACK_QML_IMPORTS QtMultimedia) -endif() - qt_add_qml_module(playback_qml URI MuseScore.Playback VERSION 1.0 @@ -93,13 +89,10 @@ qt_add_qml_module(playback_qml PlaybackToolBar.qml SoundFlagPopup.qml SoundProfilesDialog.qml + VideoPanelLoader.qml VideoPanel.qml IMPORTS ${PLAYBACK_QML_IMPORTS} ) -if (Qt6Multimedia_FOUND) - target_link_libraries(playback_qml PRIVATE Qt6::Multimedia) -endif() - fixup_qml_module_dependencies(playback_qml) diff --git a/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml new file mode 100644 index 0000000000000..0a68d1eecbd6c --- /dev/null +++ b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import QtQuick + +import Muse.Ui + +Item { + id: root + + property NavigationSection navigationSection: null + property int contentNavigationPanelOrderStart: 0 + property bool loadPanel: false + + function updateLoadedItem() { + if (!videoPanelLoader.item) { + return + } + + videoPanelLoader.item.navigationSection = root.navigationSection + videoPanelLoader.item.contentNavigationPanelOrderStart = root.contentNavigationPanelOrderStart + } + + onNavigationSectionChanged: updateLoadedItem() + onContentNavigationPanelOrderStartChanged: updateLoadedItem() + + Loader { + id: videoPanelLoader + + anchors.fill: parent + active: root.loadPanel + asynchronous: true + source: root.loadPanel ? "VideoPanel.qml" : "" + + onLoaded: { + root.updateLoadedItem() + } + } +} From b66cf8399d2d936c02b5b1a1f0353e61cd0707d8 Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:29:28 -0400 Subject: [PATCH 27/28] Use playback state for video sync --- .../MuseScore/Playback/videopanelmodel.cpp | 21 ++++++++++++++----- .../qml/MuseScore/Playback/videopanelmodel.h | 4 +--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp index ceddbb1302c90..73099fe76c359 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp @@ -42,7 +42,7 @@ VideoPanelModel::VideoPanelModel(QObject* parent) void VideoPanelModel::load() { - listenPlaybackController(); + listenPlaybackState(); context()->currentProjectChanged().onNotify(this, [this]() { listenCurrentProject(); @@ -386,7 +386,8 @@ QVariantList VideoPanelModel::hitPoints() const bool VideoPanelModel::scorePlaying() const { - return playbackController()->isPlaying(); + context::IPlaybackStatePtr playbackState = context()->playbackState(); + return playbackState && playbackState->playbackStatus() == muse::audio::PlaybackStatus::Running; } int VideoPanelModel::scorePlaybackPositionMs() const @@ -459,13 +460,23 @@ void VideoPanelModel::listenCurrentProject() }, Asyncable::Mode::SetReplace); } -void VideoPanelModel::listenPlaybackController() +void VideoPanelModel::listenPlaybackState() { - playbackController()->isPlayingChanged().onNotify(this, [this]() { + context::IPlaybackStatePtr playbackState = context()->playbackState(); + if (!playbackState) { + return; + } + + const int initialPositionMs = std::max(0, static_cast(std::lround(playbackState->playbackPosition() * 1000.0))); + if (m_scorePlaybackPositionMs != initialPositionMs) { + m_scorePlaybackPositionMs = initialPositionMs; + } + + playbackState->playbackStatusChanged().onReceive(this, [this](muse::audio::PlaybackStatus) { emit playbackSyncChanged(); }); - playbackController()->currentPlaybackPositionChanged().onReceive(this, [this](muse::audio::secs_t pos, muse::midi::tick_t) { + playbackState->playbackPositionChanged().onReceive(this, [this](muse::audio::secs_t pos) { const int positionMs = std::max(0, static_cast(std::lround(pos * 1000.0))); if (m_scorePlaybackPositionMs == positionMs) { return; diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h index 4b8ae3405274e..51763711b5c03 100644 --- a/src/playback/qml/MuseScore/Playback/videopanelmodel.h +++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h @@ -31,7 +31,6 @@ #include "modularity/ioc.h" #include "context/iglobalcontext.h" #include "project/iprojectvideosettings.h" -#include "playback/iplaybackcontroller.h" namespace mu::playback { class VideoPanelModel : public QObject, public muse::Contextable, public muse::async::Asyncable @@ -55,7 +54,6 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a QML_ELEMENT muse::ContextInject context = { this }; - muse::Inject playbackController = { this }; public: explicit VideoPanelModel(QObject* parent = nullptr); @@ -113,7 +111,7 @@ class VideoPanelModel : public QObject, public muse::Contextable, public muse::a QString musicalPositionText(int videoPositionMs) const; int parseTimecodeToMs(const QString& timecode) const; void listenCurrentProject(); - void listenPlaybackController(); + void listenPlaybackState(); int m_scorePlaybackPositionMs = 0; }; From 582fa1545139dc3a56f1bd4f7e65c3849c979c2d Mon Sep 17 00:00:00 2001 From: ToranadoMusic <280807453+ToranadoMusic@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:19:20 -0400 Subject: [PATCH 28/28] Load video panel when dock has size --- .../qml/MuseScore/AppShell/NotationPage/NotationPage.qml | 1 - src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml index a548910bca2c6..31e4367aa4937 100644 --- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml +++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml @@ -592,7 +592,6 @@ DockPage { anchors.fill: parent navigationSection: videoPanel.navigationSection contentNavigationPanelOrderStart: videoPanel.contentNavigationPanelOrderStart - loadPanel: videoPanel.visible } } ] diff --git a/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml index 0a68d1eecbd6c..de9cf8ada742e 100644 --- a/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml +++ b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml @@ -29,7 +29,8 @@ Item { property NavigationSection navigationSection: null property int contentNavigationPanelOrderStart: 0 - property bool loadPanel: false + + readonly property bool shouldLoadPanel: width > 0 && height > 0 function updateLoadedItem() { if (!videoPanelLoader.item) { @@ -47,9 +48,9 @@ Item { id: videoPanelLoader anchors.fill: parent - active: root.loadPanel + active: root.shouldLoadPanel asynchronous: true - source: root.loadPanel ? "VideoPanel.qml" : "" + source: root.shouldLoadPanel ? "VideoPanel.qml" : "" onLoaded: { root.updateLoadedItem()