diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4a5b9a7b26e67..6512bb6352d9c 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -18,6 +18,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+if (MUE_BUILD_PLAYBACK_MODULE)
+ find_package(Qt6 COMPONENTS Multimedia QUIET)
+endif()
+
# 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 15f6ecacee022..1445e82906692 100644
--- a/src/app/configs/data/shortcuts.xml
+++ b/src/app/configs/data/shortcuts.xml
@@ -771,6 +771,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 58b95a3cc0753..0cb893aa3fda7 100644
--- a/src/app/configs/data/shortcuts_azerty.xml
+++ b/src/app/configs/data/shortcuts_azerty.xml
@@ -812,6 +812,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 7fc665ed87929..51e84b1afc953 100644
--- a/src/app/configs/data/shortcuts_mac.xml
+++ b/src/app/configs/data/shortcuts_mac.xml
@@ -771,6 +771,11 @@
F12
0
+
+ toggle-video-panel
+ Ctrl+Alt+V
+ 0
+
quit
Ctrl+Q
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 883bd892640f6..6b86162f0c3fa 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",
@@ -200,6 +213,34 @@ 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(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,
@@ -253,6 +294,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)
@@ -294,6 +342,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());
@@ -323,6 +389,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 {
@@ -341,6 +420,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/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/NotationPage/NotationPage.qml b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml
index 50f18bd06ef34..a555d555f322e 100644
--- a/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml
+++ b/src/appshell/qml/MuseScore/AppShell/NotationPage/NotationPage.qml
@@ -565,6 +565,37 @@ DockPage {
}
}
}
+ },
+
+ DockPanel {
+ id: videoPanel
+
+ objectName: root.pageModel.videoPanelName()
+ title: qsTrc("appshell", "Video")
+
+ height: 360
+ 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)
+
+ VideoPanelLoader {
+ anchors.fill: parent
+ 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 30151928594e8..c2165c20311d8 100644
--- a/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp
+++ b/src/appshell/qml/MuseScore/AppShell/appmenumodel.cpp
@@ -43,6 +43,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)
@@ -145,6 +148,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)
@@ -311,11 +330,13 @@ MenuItem* AppMenuModel::makeViewMenu()
makeMenuItem("toggle-timeline"),
makeMenuItem("toggle-mixer"),
makeMenuItem("toggle-piano-keyboard"),
+ makeMenuItem("toggle-video-panel"),
makeMenuItem("toggle-percussion-panel"),
makeMenuItem("command://playback/show-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
@@ -794,6 +815,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 14dc7b477b784..48902fa1585b2 100644
--- a/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp
+++ b/src/appshell/qml/MuseScore/AppShell/notationpagemodel.cpp
@@ -39,9 +39,14 @@
using namespace mu::appshell;
using namespace mu::notation;
+using namespace mu::project;
using namespace mu::engraving;
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))
{
@@ -70,6 +75,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();
@@ -169,6 +184,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;
@@ -209,6 +229,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 47e46089143d2..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"
@@ -78,6 +79,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;
@@ -89,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/engraving/infrastructure/mscreader.cpp b/src/engraving/infrastructure/mscreader.cpp
index 5ce04dd096396..a109e948ddc06 100644
--- a/src/engraving/infrastructure/mscreader.cpp
+++ b/src/engraving/infrastructure/mscreader.cpp
@@ -238,6 +238,15 @@ ByteArray MscReader::readAudioSettingsJsonFile(const muse::io::path_t& pathPrefi
return fileData(pathPrefix.toString() + u"audiosettings.json");
}
+ByteArray MscReader::readVideoSettingsJsonFile() const
+{
+ if (!fileExists(u"videosettings.json")) {
+ return ByteArray();
+ }
+
+ 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 6cd18359002b9..6be4bef22d114 100644
--- a/src/engraving/infrastructure/mscreader.h
+++ b/src/engraving/infrastructure/mscreader.h
@@ -70,6 +70,7 @@ class MscReader
muse::ByteArray readImageFile(const muse::String& fileName) 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 582920c64118e..47ed71a28910a 100644
--- a/src/engraving/infrastructure/mscwriter.cpp
+++ b/src/engraving/infrastructure/mscwriter.cpp
@@ -193,6 +193,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 6f06c3c779263..6dbecdd590984 100644
--- a/src/engraving/infrastructure/mscwriter.h
+++ b/src/engraving/infrastructure/mscwriter.h
@@ -65,6 +65,7 @@ class MscWriter
void writeThumbnailFile(const muse::ByteArray& data);
void addImageFile(const muse::String& fileName, 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/engraving/style/styledef.cpp b/src/engraving/style/styledef.cpp
index 81e0bc9292ed0..9915c0760835c 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),
@@ -2242,6 +2260,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 5cac33544ecae..4039bfd48a362 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,
@@ -2261,6 +2279,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 85741b07501a2..5820e1c397e5b 100644
--- a/src/engraving/style/textstyle.cpp
+++ b/src/engraving/style/textstyle.cpp
@@ -775,6 +775,26 @@ 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::Position, Sid::videoHitPointPosition, Pid::POSITION },
+ } },
+ { Sid::videoHitPointPosAbove, Sid::videoHitPointPosBelow }
+};
+
const TextStyle staffTextStyle {
{ {
{ TextStylePropertyType::FontFace, Sid::staffTextFontFace, Pid::FONT_FACE },
@@ -1710,6 +1730,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::STAVE_SHARING: return &staveSharingLabelStyle;
@@ -1830,6 +1851,11 @@ 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;
+ }
_allTextStyles.push_back(TextStyleType(t));
}
}
@@ -1852,6 +1878,14 @@ 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);
+ break;
+ }
+ }
}
return _editableTextStyles;
}
diff --git a/src/engraving/types/types.h b/src/engraving/types/types.h
index a6a12dc5b5c40..86661ea51b589 100644
--- a/src/engraving/types/types.h
+++ b/src/engraving/types/types.h
@@ -897,6 +897,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 b7638f7ea8ded..3227960af8dc6 100644
--- a/src/engraving/types/typesconv.cpp
+++ b/src/engraving/types/typesconv.cpp
@@ -1760,6 +1760,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::STAVE_SHARING, "staff", muse::TranslatableString("engraving", "Stave sharing label") },
@@ -1870,6 +1871,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 de3fc1d1500f6..d67a7f516a6d6 100644
--- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp
+++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp
@@ -21,15 +21,26 @@
*/
#include "abstractnotationpaintview.h"
+#include
+#include
+
#include
#include
+#include
#include
#include "async/async.h"
#include "log.h"
#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"
#include "notation/inotationaccessibility.h" // IWYU pragma: keep
#include "notation/inotationautomation.h"
@@ -376,6 +387,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();
});
@@ -437,6 +454,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);
@@ -541,6 +561,163 @@ 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();
@@ -766,6 +943,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 83af9612b5178..ac2ca1c458de5 100644
--- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h
+++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h
@@ -270,6 +270,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/qml/MuseScore/NotationScene/timelineview.cpp b/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp
index 0369eeabd3212..039ff7634eb93 100644
--- a/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp
+++ b/src/notationscene/qml/MuseScore/NotationScene/timelineview.cpp
@@ -27,10 +27,8 @@
#include
#include
-#include "notation/inotationinteraction.h" // IWYU pragma: keep
-#include "notation/inotationundostack.h" // IWYU pragma: keep
-
-#include "widgets/timeline.h"
+#include "log.h"
+#include "project/inotationproject.h"
namespace mu::notation {
class TimelineAdapter : public QSplitter, public muse::uicomponents::IDisplayableWidget
@@ -169,6 +167,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/editstyle.cpp b/src/notationscene/widgets/editstyle.cpp
index 8d5ac6a0896fd..f79db4a44e1c5 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"
@@ -105,6 +114,7 @@ static const QStringList ALL_PAGE_CODES {
"chord-symbols",
"fretboard-diagrams",
"tablature-styles",
+ "video-scoring",
"text-styles"
};
@@ -135,6 +145,7 @@ static const QStringList ALL_TEXT_STYLE_SUBPAGE_CODES {
"repeat-text-right",
"rehearsal-mark",
"system",
+ "video-hit-point",
"staff",
"staveSharing",
"expression",
@@ -256,6 +267,58 @@ 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
// ====================================================
@@ -630,6 +693,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 },
@@ -1119,6 +1184,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);
@@ -1610,7 +1685,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;
}
@@ -1626,6 +1701,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";
@@ -2033,6 +2112,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());
@@ -2418,6 +2502,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 fa66895ef3569..b9660ba762431 100644
--- a/src/notationscene/widgets/editstyle.h
+++ b/src/notationscene/widgets/editstyle.h
@@ -39,6 +39,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
@@ -117,6 +125,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);
engraving::PropertyValue getValue(engraving::Sid idx);
diff --git a/src/notationscene/widgets/editstyleutils.cpp b/src/notationscene/widgets/editstyleutils.cpp
index a07484fbcaac2..6db63fb28ab34 100644
--- a/src/notationscene/widgets/editstyleutils.cpp
+++ b/src/notationscene/widgets/editstyleutils.cpp
@@ -296,6 +296,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/notationscene/widgets/timeline.cpp b/src/notationscene/widgets/timeline.cpp
index 141025cd251a6..a6590e51c8d16 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 "notation/inotationelements.h" // IWYU pragma: keep
#include "notation/inotationinteraction.h"
@@ -771,6 +775,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 });
@@ -1479,6 +1485,85 @@ 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;
+ }
+
+ mu::project::IProjectVideoSettingsPtr settings = videoSettings();
+ if (!settings || !settings->attachment().isValid()) {
+ return;
+ }
+
+ const mu::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 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) {
+ 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, nullptr, seg->measure(), tooltip)) {
+ (*stagger)++;
+ _globalZValue++;
+ }
+ }
+}
+
+//---------------------------------------------------------
+// Timeline::timecodeMeta
+//---------------------------------------------------------
+
+void Timeline::timecodeMeta(Segment* seg, int* stagger, int pos)
+{
+ if (!seg || seg != seg->measure()->first()) {
+ return;
+ }
+
+ mu::project::IProjectVideoSettingsPtr settings = videoSettings();
+ if (!settings || !settings->attachment().isValid()) {
+ return;
+ }
+
+ const mu::project::VideoAttachmentSettings& attachment = settings->attachment();
+ if (attachment.timecodeDisplayMode == mu::project::VideoTimecodeDisplayMode::Off) {
+ return;
+ }
+
+ 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, nullptr, seg->measure())) {
+ (*stagger)++;
+ _globalZValue++;
+ }
+}
+
//---------------------------------------------------------
// Timeline::measureMeta
//---------------------------------------------------------
@@ -2551,6 +2636,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 });
@@ -3257,6 +3344,31 @@ Score* Timeline::score() const
return m_notation ? m_notation->elements()->msScore() : nullptr;
}
+mu::project::IProjectVideoSettingsPtr Timeline::videoSettings() const
+{
+ return m_notation && m_notation->project() ? m_notation->project()->videoSettings() : nullptr;
+}
+
+QString Timeline::formatVideoTimecode(int videoPositionMs) const
+{
+ 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) * roundedFrameRate + 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 989ec2738598b..65a481c89a49d 100644
--- a/src/notationscene/widgets/timeline.h
+++ b/src/notationscene/widgets/timeline.h
@@ -30,6 +30,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"
@@ -192,6 +193,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 = "");
@@ -229,6 +232,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/internal/playbackcontroller.cpp b/src/playback/internal/playbackcontroller.cpp
index b3920447d14a1..9ec229e8e92ff 100644
--- a/src/playback/internal/playbackcontroller.cpp
+++ b/src/playback/internal/playbackcontroller.cpp
@@ -1128,6 +1128,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()) {
@@ -1440,7 +1469,7 @@ void PlaybackController::setupPlayback()
const AudioOutputParams& masterOutputParams = audioSettings()->masterAudioOutputParams();
playback()->setMasterFxChainParams(masterOutputParams.fxChain);
playback()->setMasterAuxSendsParams(masterOutputParams.auxSends);
- playback()->setMasterControlParams(masterOutputParams.control());
+ updateMasterControlParams();
subscribeOnAudioParamsChanges();
setupTracks();
@@ -1560,6 +1589,13 @@ void PlaybackController::setupTracks()
updateSoloMuteStates();
});
+ if (videoSettings()) {
+ videoSettings()->settingsChanged().onNotify(this, [this]() {
+ updateMasterControlParams();
+ }, Asyncable::Mode::SetReplace);
+ }
+
+ m_isPlayAllowedChanged.notify();
m_isPlayAllowedChanged.send(isPlayAllowed());
}
diff --git a/src/playback/internal/playbackcontroller.h b/src/playback/internal/playbackcontroller.h
index a62541f8127a0..69530869de8c1 100644
--- a/src/playback/internal/playbackcontroller.h
+++ b/src/playback/internal/playbackcontroller.h
@@ -45,6 +45,7 @@
#include "../iplaybackcontroller.h"
#include "../iplaybackconfiguration.h"
#include "../isoundprofilesrepository.h"
+#include "project/iprojectvideosettings.h"
namespace mu::playback {
class OnlineSoundsController;
@@ -212,6 +213,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/CMakeLists.txt b/src/playback/qml/MuseScore/Playback/CMakeLists.txt
index 279583f143e4d..0ce0321237c7e 100644
--- a/src/playback/qml/MuseScore/Playback/CMakeLists.txt
+++ b/src/playback/qml/MuseScore/Playback/CMakeLists.txt
@@ -20,6 +20,12 @@
muse_create_qml_module(playback_qml FOR playback)
+set(PLAYBACK_QML_IMPORTS
+ TARGET muse_ui_qml
+ TARGET muse_uicomponents_qml
+ TARGET notationscene_qml
+)
+
qt_add_qml_module(playback_qml
URI MuseScore.Playback
VERSION 1.0
@@ -51,6 +57,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,10 +89,10 @@ qt_add_qml_module(playback_qml
PlaybackToolBar.qml
SoundFlagPopup.qml
SoundProfilesDialog.qml
+ VideoPanelLoader.qml
+ VideoPanel.qml
IMPORTS
- TARGET muse_ui_qml
- TARGET muse_uicomponents_qml
- TARGET notationscene_qml
+ ${PLAYBACK_QML_IMPORTS}
)
fixup_qml_module_dependencies(playback_qml)
diff --git a/src/playback/qml/MuseScore/Playback/MixerPanel.qml b/src/playback/qml/MuseScore/Playback/MixerPanel.qml
index 0cbe7bccad710..edab950bd5276 100644
--- a/src/playback/qml/MuseScore/Playback/MixerPanel.qml
+++ b/src/playback/qml/MuseScore/Playback/MixerPanel.qml
@@ -193,155 +193,155 @@ ColumnLayout {
contentColumn.completed = true
}
- MixerSoundSection {
- id: soundSection
+ 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)
+ }
}
- }
}
}
diff --git a/src/playback/qml/MuseScore/Playback/VideoPanel.qml b/src/playback/qml/MuseScore/Playback/VideoPanel.qml
new file mode 100644
index 0000000000000..406621a5b7e90
--- /dev/null
+++ b/src/playback/qml/MuseScore/Playback/VideoPanel.qml
@@ -0,0 +1,890 @@
+/*
+ * 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.Controls
+import QtQuick.Layouts
+import QtMultimedia
+
+import Muse.Ui
+import Muse.UiComponents
+import MuseScore.Playback
+
+Item {
+ id: root
+
+ property NavigationSection navigationSection: null
+ property int contentNavigationPanelOrderStart: 0
+
+ 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
+
+ VideoPanelModel {
+ 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 clearAttachedVideo() {
+ video.stop()
+ video.source = ""
+ 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) {
+ if (!videoModel.hasVideo || video.duration <= 0) {
+ 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)
+ }
+
+ 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)
+ }
+ }
+
+ Item {
+ id: contentItem
+
+ anchors.fill: parent
+ anchors.margins: root.contentMargin
+
+ Rectangle {
+ 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"
+ border.width: ui.theme.borderWidth
+ border.color: ui.theme.strokeColor
+ clip: true
+
+ Item {
+ id: videoFrame
+
+ 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
+
+ anchors.centerIn: parent
+ width: Math.round(limitedByHeight ? availableHeight * aspectRatio : availableWidth)
+ height: Math.round(limitedByHeight ? availableHeight : availableWidth / aspectRatio)
+
+ Video {
+ id: video
+
+ 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)
+ }
+ }
+
+ 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: videoFrame
+ 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()
+ }
+ }
+
+ }
+
+ StyledFlickable {
+ id: controlsFlickable
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: previewSlot.bottom
+ anchors.topMargin: 8
+ anchors.bottom: parent.bottom
+
+ contentHeight: controlsColumn.implicitHeight
+ interactive: contentHeight > height
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ readonly property bool overflowing: contentHeight > height + 1
+
+ ScrollBar.vertical: StyledScrollBar {
+ policy: controlsFlickable.overflowing ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded
+ padding: 0
+ }
+ ColumnLayout {
+ id: controlsColumn
+
+ width: Math.max(0, controlsFlickable.width - (controlsFlickable.overflowing ? root.controlsScrollbarReserve : 0))
+ 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()
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.preferredHeight: videoModel.hitPoints.length > 0 ? 70 : 58
+
+ StyledSlider {
+ id: positionSlider
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: videoModel.hitPoints.length > 0 ? 17 : 8
+ 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)
+ }
+ }
+
+ 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, (displayTimeMs / Math.max(video.duration, 1)) * parent.width - (width / 2)))
+ y: 13
+ width: 3
+ 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
+ }
+ }
+ }
+ }
+ }
+
+ StyledTextLabel {
+ Layout.preferredWidth: root.compactMode ? 86 : 116
+ horizontalAlignment: Text.AlignRight
+ text: videoModel.hasVideo ? videoModel.formatTimecode(video.position) : ""
+ }
+ }
+
+ 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.IconOnly
+ navigation: navigationPanel
+ navigationRowOrderStart: root.contentNavigationPanelOrderStart + 4
+
+ onPathEdited: function(newPath) {
+ videoModel.videoPath = newPath
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ StyledTextLabel {
+ text: qsTrc("playback", "Offset")
+ }
+
+ FlatButton {
+ text: root.compactMode ? qsTrc("playback", "-100") : qsTrc("playback", "-100 ms")
+ enabled: videoModel.hasVideo
+ navigation.panel: navigationPanel
+ navigation.order: root.contentNavigationPanelOrderStart + 6
+
+ onClicked: {
+ videoModel.nudgeOffset(-100)
+ }
+ }
+
+ TextInputField {
+ Layout.preferredWidth: root.compactMode ? 64 : 88
+ currentText: videoModel.offsetMs.toString()
+ enabled: videoModel.hasVideo
+ navigation.panel: navigationPanel
+ navigation.order: root.contentNavigationPanelOrderStart + 7
+
+ onTextEditingFinished: function(newTextValue) {
+ var parsedValue = parseInt(newTextValue, 10)
+ if (!isNaN(parsedValue)) {
+ videoModel.offsetMs = parsedValue
+ }
+ }
+ }
+
+ StyledTextLabel {
+ text: qsTrc("playback", "ms")
+ }
+
+ FlatButton {
+ text: root.compactMode ? qsTrc("playback", "+100") : qsTrc("playback", "+100 ms")
+ enabled: videoModel.hasVideo
+ navigation.panel: navigationPanel
+ navigation.order: root.contentNavigationPanelOrderStart + 8
+
+ onClicked: {
+ videoModel.nudgeOffset(100)
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 4
+ visible: videoModel.hasVideo
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ StyledTextLabel {
+ Layout.fillWidth: true
+ text: videoModel.hasVideo ? videoModel.hitPoints.length + " " + qsTrc("playback", "hit points") : ""
+ }
+
+ 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", "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 + 11
+
+ onClicked: {
+ videoModel.addHitPoint(video.position)
+ }
+ }
+ }
+
+ StyledFlickable {
+ id: hitPointsFlickable
+
+ Layout.fillWidth: true
+ 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.overflowing ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded
+ padding: 0
+ }
+
+ ColumnLayout {
+ id: hitPointsColumn
+
+ 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
+
+ RowLayout {
+ id: hitPointDelegate
+
+ required property var modelData
+ required property int index
+
+ property bool editingLabel: false
+ property bool editingTimecode: false
+
+ width: hitPointsColumn.width
+ spacing: 8
+
+ Item {
+ Layout.preferredWidth: 92
+ 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
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 28
+
+ StyledTextLabel {
+ anchors.fill: parent
+ verticalAlignment: Text.AlignVCenter
+ text: hitPointDelegate.modelData.label
+ 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)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ FlatButton {
+ text: qsTrc("playback", "Clear")
+ enabled: videoModel.hasVideo
+ navigation.panel: navigationPanel
+ navigation.order: root.contentNavigationPanelOrderStart + 30
+
+ onClicked: {
+ root.clearAttachedVideo()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ NavigationPanel {
+ id: navigationPanel
+ name: "VideoPanel"
+ section: root.navigationSection
+ order: root.contentNavigationPanelOrderStart
+ direction: NavigationPanel.Vertical
+ }
+}
diff --git a/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml
new file mode 100644
index 0000000000000..de9cf8ada742e
--- /dev/null
+++ b/src/playback/qml/MuseScore/Playback/VideoPanelLoader.qml
@@ -0,0 +1,59 @@
+/*
+ * 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
+
+ readonly property bool shouldLoadPanel: width > 0 && height > 0
+
+ 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.shouldLoadPanel
+ asynchronous: true
+ source: root.shouldLoadPanel ? "VideoPanel.qml" : ""
+
+ onLoaded: {
+ root.updateLoadedItem()
+ }
+ }
+}
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/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 43ab824c8f6af..0ac6cb9118440 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 21d61d0ab8127..cdc76c2cc9946 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"
#include "log.h"
@@ -38,6 +41,19 @@ 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)
+{
+ 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)
: QAbstractListModel(parent), muse::Contextable(muse::iocCtxForQmlObject(this))
@@ -158,6 +174,10 @@ void MixerPanelModel::reloadItems()
}
}
+ if (videoSettings() && videoSettings()->attachment().isValid()) {
+ m_mixerChannelList.push_back(buildVideoChannelItem());
+ }
+
addInstrumentTrack(notationPlayback()->metronomeTrackId());
const auto& auxTrackIdMap = controller()->auxTrackIdMap();
@@ -257,7 +277,9 @@ void MixerPanelModel::clear()
TRACEFUNC;
m_masterChannelItem = nullptr;
- qDeleteAll(m_mixerChannelList);
+ for (MixerChannelItem* item : m_mixerChannelList) {
+ item->deleteLater();
+ }
m_mixerChannelList.clear();
}
@@ -323,6 +345,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
@@ -534,6 +580,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*/);
@@ -555,7 +655,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) {
@@ -624,6 +730,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 f78076c95fe7a..1d47216b052c4 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
new file mode 100644
index 0000000000000..73099fe76c359
--- /dev/null
+++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.cpp
@@ -0,0 +1,488 @@
+/*
+ * 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
+#include
+
+#include
+
+#include "engraving/dom/masterscore.h"
+#include "engraving/dom/measure.h"
+#include "engraving/dom/score.h"
+#include "engraving/types/constants.h"
+
+using namespace mu::playback;
+using namespace mu::project;
+
+VideoPanelModel::VideoPanelModel(QObject* parent)
+ : QObject(parent), muse::Contextable(muse::iocCtxForQmlObject(this))
+{
+}
+
+void VideoPanelModel::load()
+{
+ listenPlaybackState();
+
+ 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);
+}
+
+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);
+}
+
+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);
+}
+
+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);
+ 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) * roundedFrameRate + 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'));
+}
+
+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();
+}
+
+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();
+ 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);
+}
+
+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;
+}
+
+void VideoPanelModel::setMuted(bool muted)
+{
+ VideoAttachmentSettings updated = attachment();
+ if (!updated.isValid() || updated.muted == muted) {
+ return;
+ }
+
+ updated.muted = 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);
+}
+
+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
+{
+ context::IPlaybackStatePtr playbackState = context()->playbackState();
+ return playbackState && playbackState->playbackStatus() == muse::audio::PlaybackStatus::Running;
+}
+
+int VideoPanelModel::scorePlaybackPositionMs() const
+{
+ return m_scorePlaybackPositionMs;
+}
+
+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);
+}
+
+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();
+ }
+
+ 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));
+ 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();
+ if (!settings) {
+ return;
+ }
+
+ settings->settingsChanged().onNotify(this, [this]() {
+ emit videoSettingsChanged();
+ }, Asyncable::Mode::SetReplace);
+}
+
+void VideoPanelModel::listenPlaybackState()
+{
+ 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();
+ });
+
+ 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;
+ }
+
+ m_scorePlaybackPositionMs = positionMs;
+ emit playbackSyncChanged();
+ });
+}
diff --git a/src/playback/qml/MuseScore/Playback/videopanelmodel.h b/src/playback/qml/MuseScore/Playback/videopanelmodel.h
new file mode 100644
index 0000000000000..51763711b5c03
--- /dev/null
+++ b/src/playback/qml/MuseScore/Playback/videopanelmodel.h
@@ -0,0 +1,118 @@
+/*
+ * 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
+#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(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(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)
+
+ 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);
+ 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;
+ QString videoPath() const;
+ QUrl videoUrl() const;
+ void setVideoPath(const QString& path);
+
+ int offsetMs() const;
+ void setOffsetMs(int offsetMs);
+
+ int volumePercent() const;
+ void setVolumePercent(int volumePercent);
+
+ int balance() const;
+ void setBalance(int balance);
+
+ bool muted() const;
+ void setMuted(bool muted);
+
+ 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;
+
+signals:
+ void videoSettingsChanged();
+ void playbackSyncChanged();
+
+private:
+ 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;
+ int parseTimecodeToMs(const QString& timecode) const;
+ void listenCurrentProject();
+ void listenPlaybackState();
+
+ int m_scorePlaybackPositionMs = 0;
+};
+}
diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt
index 40e4cff18fb7f..c57c1fa9f48f5 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
@@ -52,6 +53,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 c387b5a841a15..01f5f8fec12ea 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"
@@ -82,6 +83,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 47e15276d83aa..ced692d1ae92f 100644
--- a/src/project/internal/notationproject.cpp
+++ b/src/project/internal/notationproject.cpp
@@ -54,6 +54,7 @@
#include "notation/internal/masternotation.h"
#include "notation/notationerrors.h"
#include "projectaudiosettings.h"
+#include "projectvideosettings.h"
#include "projectfileinfoprovider.h"
#include "projecterrors.h"
#include "types/projectcreateoptions.h"
@@ -100,6 +101,7 @@ QString NotationProject::scoreDefaultTitle()
NotationProject::~NotationProject()
{
m_projectAudioSettings = nullptr;
+ m_projectVideoSettings = nullptr;
m_masterNotation = nullptr;
m_engravingProject = nullptr;
}
@@ -112,6 +114,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_)
@@ -238,6 +241,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();
@@ -337,6 +346,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();
@@ -383,6 +393,7 @@ Ret NotationProject::createNew(const ProjectCreateOptions& projectOptions)
// Setup audio settings
m_projectAudioSettings->makeDefault();
+ m_projectVideoSettings->makeDefault();
// Setup view state
m_masterNotation->notation()->viewState()->makeDefault();
@@ -910,6 +921,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);
@@ -1103,6 +1121,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)
@@ -1268,3 +1291,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 a2c0823fa61f6..2bc67e07d3cbc 100644
--- a/src/project/internal/notationproject.h
+++ b/src/project/internal/notationproject.h
@@ -37,6 +37,7 @@
#include "notation/inotationconfiguration.h"
#include "projectaudiosettings.h"
+#include "projectvideosettings.h"
#include "iprojectmigrator.h"
#include "global/iglobalconfiguration.h"
@@ -113,6 +114,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();
@@ -141,6 +143,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..b0b3da90cd0c5
--- /dev/null
+++ b/src/project/internal/projectvideosettings.cpp
@@ -0,0 +1,191 @@
+/*
+ * 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
+#include
+#include
+#include
+
+#include "types/bytearray.h"
+
+using namespace mu::project;
+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;
+}
+
+void ProjectVideoSettings::setAttachment(const VideoAttachmentSettings& attachment)
+{
+ VideoAttachmentSettings normalized = attachment;
+ normalizeHitPoints(normalized);
+
+ if (m_attachment == normalized) {
+ return;
+ }
+
+ m_attachment = normalized;
+ 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);
+ }
+
+ 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);
+}
+
+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();
+}
+
+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;
+ 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);
+ 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()));
+ }
+ }
+
+ normalizeHitPoints(result);
+
+ 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["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
new file mode 100644
index 0000000000000..a775aefc5bf0c
--- /dev/null
+++ b/src/project/internal/projectvideosettings.h
@@ -0,0 +1,59 @@
+/*
+ * 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:
+ VideoHitPointSettings hitPointFromJson(const QJsonObject& object) const;
+ QJsonObject hitPointToJson(const VideoHitPointSettings& hitPoint) const;
+ 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..097dc36aefe19
--- /dev/null
+++ b/src/project/iprojectvideosettings.h
@@ -0,0 +1,107 @@
+/*
+ * 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/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;
+ int offsetMs = 0;
+ float volume = 1.f;
+ float balance = 0.f;
+ bool muted = false;
+ bool solo = false;
+ double frameRate = 24.0;
+ VideoTimecodeDisplayMode timecodeDisplayMode = VideoTimecodeDisplayMode::Off;
+ std::vector hitPoints;
+
+ bool isValid() const
+ {
+ return !path.empty();
+ }
+
+ bool operator==(const VideoAttachmentSettings& other) const
+ {
+ return path == other.path
+ && offsetMs == other.offsetMs
+ && volume == other.volume
+ && balance == other.balance
+ && muted == other.muted
+ && solo == other.solo
+ && frameRate == other.frameRate
+ && timecodeDisplayMode == other.timecodeDisplayMode
+ && hitPoints == other.hitPoints;
+ }
+
+ 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..336b259389457
--- /dev/null
+++ b/src/project/tests/projectvideosettingstest.cpp
@@ -0,0 +1,150 @@
+/*
+ * 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;
+
+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;
+ 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, 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;
+ 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.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));
+ 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);
+}