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); +}