using ImGuiNET; using T3.Core.Animation; using T3.Core.Audio; using T3.Core.DataTypes.DataSet; using T3.Core.DataTypes.Vector; using T3.Core.IO; using T3.Core.Operator; using T3.Core.Settings; using T3.Core.Utils; using T3.Editor.Gui.Interaction; using T3.Editor.Gui.Interaction.Keyboard; using T3.Editor.Gui.Interaction.Timing; using T3.Editor.Gui.OutputUi; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.Gui.Windows.Layouts; using T3.Editor.UiModel; using T3.Editor.UiModel.InputsAndTypes; using Icon = T3.Editor.Gui.Styling.Icon; using Vector2 = System.Numerics.Vector2; using Vector4 = System.Numerics.Vector4; // ReSharper disable CompareOfFloatsByEqualityOperator namespace T3.Editor.Gui.Windows.TimeLine; [HelpUiID("ControlBar")] internal static class TimeControls { internal static void HandleTimeControlActions() { var playback = Playback.Current; // TODO, this should be non-static eventually if (UserActions.TapBeatSync.Triggered()) BeatTiming.TriggerSyncTap(); if (UserActions.TapBeatSyncMeasure.Triggered()) BeatTiming.TriggerResyncMeasure(); if (UserActions.PlaybackJumpToPreviousKeyframe.Triggered()) UserActionRegistry.QueueAction(UserActions.PlaybackJumpToPreviousKeyframe); if (UserActions.PlaybackJumpToStartTime.Triggered()) playback.TimeInBars = playback.IsLooping ? playback.LoopRange.Start : 0; if (UserActions.PlaybackJumpToPreviousKeyframe.Triggered()) UserActionRegistry.QueueAction(UserActions.PlaybackJumpToPreviousKeyframe); { //const float editFrameRate = 30; var frameDuration = UserSettings.Config.FrameStepAmount switch { FrameStepAmount.FrameAt60Fps => 1 / 60f, FrameStepAmount.FrameAt30Fps => 1 / 30f, FrameStepAmount.FrameAt15Fps => 1 / 15f, FrameStepAmount.Bar => (float)playback.SecondsFromBars(1), FrameStepAmount.Beat => (float)playback.SecondsFromBars(1 / 4f), FrameStepAmount.Tick => (float)playback.SecondsFromBars(1 / 16f), _ => 1 }; var editFrameRate = 1 / frameDuration; // Step to previous frame if (UserActions.PlaybackPreviousFrame.Triggered()) { var rounded = Math.Round(playback.TimeInSecs * editFrameRate) / editFrameRate; playback.TimeInSecs = rounded - frameDuration; } if (UserActions.PlaybackJumpBack.Triggered()) { playback.TimeInBars -= 1; } // Step to next frame if (UserActions.PlaybackNextFrame.Triggered()) { var rounded = Math.Round(playback.TimeInSecs * editFrameRate) / editFrameRate; playback.TimeInSecs = rounded + frameDuration; } } // Play backwards with increasing speed if (UserActions.PlaybackBackwards.Triggered()) { Log.Debug("Backwards triggered with speed " + playback.PlaybackSpeed); if (playback.PlaybackSpeed >= 0) { playback.PlaybackSpeed = -1; } else if (playback.PlaybackSpeed > -16) { playback.PlaybackSpeed *= 2; } } // Play forward with increasing speed if (UserActions.PlaybackForward.Triggered()) { if (playback.PlaybackSpeed <= 0) { _lastPlaybackStartTime = playback.TimeInBars; playback.PlaybackSpeed = 1; } else if (playback.PlaybackSpeed < 16) // Bass can't play much faster anyways { playback.PlaybackSpeed *= 2; } } if (UserActions.PlaybackForwardHalfSpeed.Triggered()) { if (playback.PlaybackSpeed is > 0 and < 1f) playback.PlaybackSpeed *= 0.5f; else playback.PlaybackSpeed = 0.5f; } // Stop as separate keyboard action if (UserActions.PlaybackStop.Triggered()) { playback.PlaybackSpeed = 0; if (UserSettings.Config.ResetTimeAfterPlayback) playback.TimeInBars = _lastPlaybackStartTime; } if (UserActions.PlaybackToggle.Triggered()) { if (playback.PlaybackSpeed == 0) { playback.PlaybackSpeed = 1; _lastPlaybackStartTime = playback.TimeInBars; } else { playback.PlaybackSpeed = 0; if (UserSettings.Config.ResetTimeAfterPlayback) playback.TimeInBars = _lastPlaybackStartTime; } } if (UserActions.SetStartTime.Triggered()) { Playback.Current.IsLooping = true; Playback.Current.LoopRange.Start = (float)Playback.Current.TimeInBars; if(Playback.Current.LoopRange.End < Playback.Current.LoopRange.Start) Playback.Current.LoopRange.End = Playback.Current.LoopRange.Start + 4; } if (UserActions.SetEndTime.Triggered()) { Playback.Current.IsLooping = true; Playback.Current.LoopRange.End = (float)Playback.Current.TimeInBars; if(Playback.Current.LoopRange.Start > Playback.Current.LoopRange.End) Playback.Current.LoopRange.Start = Playback.Current.LoopRange.End - 4; } if (UserActions.PlaybackJumpToNextKeyframe.Triggered()) UserActionRegistry.QueueAction(UserActions.PlaybackJumpToNextKeyframe); if (UserActions.PlaybackJumpToPreviousKeyframe.Triggered()) UserActionRegistry.QueueAction(UserActions.PlaybackJumpToPreviousKeyframe); } internal static void DrawTimeControls(TimeLineCanvas timeLineCanvas, Instance composition) { var playback = Playback.Current; // Settings PlaybackUtils.FindCompositionSettingsForInstance(composition, out var compositionWithSettings, out var settings); var opHasSettings = compositionWithSettings == composition; // if (CustomComponents.IconButton(CoreSettings.Config.AudioMuted ? Icon.ToggleAudioOff : Icon.ToggleAudioOn, // ControlSize, // CoreSettings.Config.AudioMuted // ? CustomComponents.ButtonStates.NeedsAttention // : CustomComponents.ButtonStates.Default // )) // { // CoreSettings.Config.AudioMuted = !CoreSettings.Config.AudioMuted; // AudioEngine.SetMute(CoreSettings.Config.AudioMuted); // } if (CustomComponents.IconButton(Icon.Settings, ControlSize, opHasSettings ? CustomComponents.ButtonStates.Emphasized : CustomComponents.ButtonStates.Default)) { WindowManager.ToggleInstanceVisibility(); } CustomComponents.TooltipForLastItem("Project Settings", "Switch between soundtrack and VJ modes. Control BPM and other inputs."); ImGui.SameLine(); // Current Time var delta = 0.0; string formattedTime = ""; switch (UserSettings.Config.TimeDisplayMode) { case TimeFormat.TimeDisplayModes.Bars: formattedTime = TimeFormat.FormatTimeInBars(playback.TimeInBars, 0); break; case TimeFormat.TimeDisplayModes.Secs: var ts = TimeSpan.FromSeconds(playback.TimeInSecs); formattedTime = ts.Hours > 0 ? ts.ToString(@"hh\:mm\:ss\:ff") : ts.ToString(@"mm\:ss\:ff"); break; case TimeFormat.TimeDisplayModes.F30: var frames = playback.TimeInSecs * 30; formattedTime = $"{frames:0}f "; break; case TimeFormat.TimeDisplayModes.F60: var frames60 = playback.TimeInSecs * 60; formattedTime = $"{frames60:0}f "; break; } ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba); if (JogDial(formattedTime, ref delta, new Vector2(StandardWidth, ControlSize.Y))) { playback.PlaybackSpeed = 0; playback.TimeInBars += delta; if (UserSettings.Config.TimeDisplayMode == TimeFormat.TimeDisplayModes.F30) { playback.TimeInSecs = Math.Floor(playback.TimeInSecs * 30) / 30; } } ImGui.PopStyleColor(); if(ImGui.IsItemHovered()) CustomComponents.TooltipForLastItem($"Current playtime at {settings.Playback.Bpm:0.0} BPM.", "Click mode button to toggle between timeline formats."); ImGui.SameLine(); // Time Mode with context menu ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba); if (ImGui.Button(UserSettings.Config.TimeDisplayMode.ToString(), ControlSize * new Vector2(1.5f,1))) { UserSettings.Config.TimeDisplayMode = (TimeFormat.TimeDisplayModes)(((int)UserSettings.Config.TimeDisplayMode + 1) % Enum.GetNames(typeof(TimeFormat.TimeDisplayModes)).Length); } ImGui.PopStyleColor(); CustomComponents.TooltipForLastItem("Timeline format", "Click to toggle through BPM, Frames and Normal time modes"); ImGui.SameLine(); // Idle motion { ImGui.PushStyleColor(ImGuiCol.Text, UserSettings.Config.EnableIdleMotion ? UiColors.TextDisabled : new Vector4(0, 0, 0, 0.5f)); // Create invisible button with same size as icon buttons would have if (ImGui.Button("##idleMotionToggle", ControlSize)) { UserSettings.Config.EnableIdleMotion = !UserSettings.Config.EnableIdleMotion; } // Tooltip (same as before) CustomComponents.TooltipForLastItem("Idle Motion - Keeps beat time running", "This will keep updating the output [Time]\nwhich is useful for procedural animation and syncing."); var drawList = ImGui.GetWindowDrawList(); var min = ImGui.GetItemRectMin(); var max = ImGui.GetItemRectMax(); var center = (min + max) * 0.5f; // Draw beat grid background const int cellCount = 4; var cellSize = CellSize; var gridOffset = center - GridSize; // Pre-calculate the cell size minus 1 for the rect size var rectSize = new Vector2(cellSize - 1, cellSize - 1); var gridColor = UserSettings.Config.EnableIdleMotion ? UiColors.BackgroundFull.Fade(0.2f) : UiColors.ForegroundFull.Fade(0.1f); for (int x = 0; x < cellCount; x++) { for (int y = 0; y < cellCount; y++) { var cellMin = gridOffset + new Vector2(x * cellSize, y * cellSize); var cellMax = cellMin + rectSize; drawList.AddRectFilled(cellMin, cellMax, gridColor); } } if (!UserSettings.Config.EnableIdleMotion) { var diagonal = new Vector2(CellSize, -CellSize) * 2.3f; var lineStart = center + diagonal; var lineEnd = center - diagonal; drawList.AddLine(lineStart, lineEnd, UiColors.BackgroundPopup.Fade(0.4f),5); drawList.AddLine(lineStart, lineEnd, UiColors.ForegroundFull.Fade(0.1f)); } // If idle motion is enabled, draw the animated indicator if (UserSettings.Config.EnableIdleMotion) { var beat = (int)(playback.FxTimeInBars * 4) % 4; var beatPulse = (playback.FxTimeInBars * 4) % 4 - beat; var bar = (int)(playback.FxTimeInBars) % 4; if (beat < 0) beat = 4 + beat; if (bar < 0) bar = 4 + bar; var indicatorMin = gridOffset + new Vector2(beat * cellSize, bar * cellSize); var indicatorMax = indicatorMin + new Vector2(cellSize - 1, cellSize - 1); drawList.AddRectFilled(indicatorMin, indicatorMax, Color.Mix(UiColors.StatusAnimated, UiColors.BackgroundFull.Fade(0.3f), (float)beatPulse)); } ImGui.PopStyleColor(); ImGui.SameLine(); } // MidiIndicator { var timeSinceLastEvent = Playback.RunTimeInSecs - Math.Max(T3Ui.MidiDataRecording.LastEventTime, T3Ui.OscDataRecording.LastEventTime); var flashFactor = MathF.Pow((float)timeSinceLastEvent.Clamp(0, 1) / 1, 0.5f); var color = Color.Mix(UiColors.StatusAnimated, UiColors.BackgroundFull.Fade(0.3f), flashFactor); if (CustomComponents.IconButton(Icon.IO, ControlSize, color)) { //T3Ui.MidiStreamRecorder.Reset(); //DataRecording.ActiveRecordingSet.WriteToFile(); WindowManager.ToggleInstanceVisibility(); } if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); if (timeSinceLastEvent < 10) { ImGui.BeginChild("canvas", new Vector2(400, 250)); //DataSetOutputUi.DrawDataSet(dataSet); _dataSetView.Draw(DataRecording.ActiveRecordingSet); ImGui.EndChild(); } else { ImGui.Text("Midi and OSC input indicator\nClick to open IO window."); } ImGui.EndTooltip(); } ImGui.SameLine(); } if (settings.Playback.Syncing == CompositionSettings.SyncModes.Tapping) { var bpm = BeatTiming.Bpm; if (SingleValueEdit.Draw(ref bpm, new Vector2(StandardWidth, ControlSize.Y), min: 1, max: 360, clampMin: true, clampMax: true, scale: 0.01f, format: "{0:0.0 BPM}") == InputEditStateFlags.Modified) { composition.Symbol.GetSymbolUi().FlagAsModified(); BeatTiming.SetBpmRate(bpm); } ImGui.SameLine(); ImGui.Button("Sync", SyncButton); if (ImGui.IsItemHovered()) { if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { if (ImGui.GetIO().KeyCtrl) { var roundedBpm = Math.Round(BeatTiming.Bpm*2)/2; BeatTiming.SetBpmRate((float)roundedBpm); } else { BeatTiming.TriggerSyncTap(); } } else if (ImGui.IsMouseClicked(ImGuiMouseButton.Right)) { BeatTiming.TriggerResyncMeasure(); } } CustomComponents.TooltipForLastItem("Click on beat to sync. Tap later once to refine. Click right to sync measure.\n" + "Ctrl+Click to round BPM", $"Tap: {UserActions.TapBeatSync.ListShortcuts()}\n" + $"Resync: {UserActions.TapBeatSyncMeasure.ListShortcuts()}"); ImGui.SameLine(); } else { // Jump to start if (CustomComponents.IconButton(Icon.JumpToRangeStart, ControlSize, playback.TimeInBars != playback.LoopRange.Start ? CustomComponents.ButtonStates.Default : CustomComponents.ButtonStates.Disabled ) || UserActions.PlaybackJumpToStartTime.Triggered() ) { playback.TimeInBars = playback.IsLooping ? playback.LoopRange.Start : 0; } CustomComponents.TooltipForLastItem("Jump to beginning", UserActions.PlaybackJumpToStartTime.ListShortcuts()); ImGui.SameLine(); // Prev Keyframe if (CustomComponents.IconButton(Icon.JumpToPreviousKeyframe, ControlSize, FrameStats.Last.HasKeyframesBeforeCurrentTime ? CustomComponents.ButtonStates.Default : CustomComponents.ButtonStates.Disabled) ) { UserActionRegistry.QueueAction(UserActions.PlaybackJumpToPreviousKeyframe); } CustomComponents.TooltipForLastItem("Jump to previous keyframe", UserActions.PlaybackJumpToPreviousKeyframe.ListShortcuts()); ImGui.SameLine(); // Play backwards if (CustomComponents.IconButton(Icon.PlayBackwards, ControlSize, playback.PlaybackSpeed < 0 ? CustomComponents.ButtonStates.Activated : CustomComponents.ButtonStates.Default )) { if (playback.PlaybackSpeed != 0) { playback.PlaybackSpeed = 0; } else if (playback.PlaybackSpeed == 0) { playback.PlaybackSpeed = -1; } } if (playback.PlaybackSpeed < -1) { ImGui.GetWindowDrawList().AddText(ImGui.GetItemRectMin() + new Vector2(20, 4), UiColors.ForegroundFull, $"×{-playback.PlaybackSpeed:0}"); } CustomComponents.TooltipForLastItem("Play backwards", "Play backwards (and faster): " + UserActions.PlaybackBackwards.ListShortcuts() + "\nPrevious frame:" + UserActions.PlaybackPreviousFrame.ListShortcuts()); ImGui.SameLine(); // Play forward if (CustomComponents.IconButton(Icon.PlayForwards, ControlSize, playback.PlaybackSpeed > 0 ? CustomComponents.ButtonStates.Activated : CustomComponents.ButtonStates.Default )) { if (Math.Abs(playback.PlaybackSpeed) > 0.001f) { playback.PlaybackSpeed = 0; } else if (Math.Abs(playback.PlaybackSpeed) < 0.001f) { playback.PlaybackSpeed = 1; } } if (playback.PlaybackSpeed > 1) { ImGui.GetWindowDrawList().AddText(ImGui.GetItemRectMin() + new Vector2(20, 4), UiColors.ForegroundFull, $"×{playback.PlaybackSpeed:0}"); } CustomComponents.TooltipForLastItem("Start playback", "Play forward (and faster): " + UserActions.PlaybackForward.ListShortcuts() + "\nPlay half speed (and slower): " + UserActions.PlaybackForwardHalfSpeed.ListShortcuts() + "\nNext frame:" + UserActions.PlaybackNextFrame.ListShortcuts()); ImGui.SameLine(); // Next Keyframe if (CustomComponents.IconButton(Icon.JumpToNextKeyframe, ControlSize, FrameStats.Last.HasKeyframesAfterCurrentTime ? CustomComponents.ButtonStates.Default : CustomComponents.ButtonStates.Disabled) ) { UserActionRegistry.QueueAction(UserActions.PlaybackJumpToNextKeyframe); } CustomComponents.TooltipForLastItem("Jump to next keyframe", UserActions.PlaybackJumpToNextKeyframe.ListShortcuts()); ImGui.SameLine(); // // End // Loop if (CustomComponents.IconButton(Icon.Loop, ControlSize, playback.IsLooping ? CustomComponents.ButtonStates.Activated : CustomComponents.ButtonStates.Default)) { playback.IsLooping = !playback.IsLooping; var loopRangeMatchesTime = playback.LoopRange.IsValid && playback.LoopRange.Contains(playback.TimeInBars); if (playback.IsLooping && !loopRangeMatchesTime) { playback.LoopRange.Start = (float)(playback.TimeInBars - playback.TimeInBars % 4); playback.LoopRange.Duration = 4; } } CustomComponents.TooltipForLastItem("Loop playback", "This will initialize one bar around current time."); ImGui.SameLine(); // Curve Mode var hasKeyframes = FrameStats.Current.HasKeyframesAfterCurrentTime || FrameStats.Current.HasKeyframesAfterCurrentTime; ImGui.PushStyleColor(ImGuiCol.Text, hasKeyframes ? UiColors.Text.Rgba : UiColors.TextMuted); if (ImGui.Button(timeLineCanvas.Mode.ToString(), DopeCurve)) // { timeLineCanvas.Mode = (TimeLineCanvas.Modes)(((int)timeLineCanvas.Mode + 1) % Enum.GetNames(typeof(TimeLineCanvas.Modes)).Length); } ImGui.PopStyleColor(); CustomComponents.TooltipForLastItem("Toggle keyframe view between Dope sheet and Curve mode."); ImGui.SameLine(); } // ToggleAudio if (CustomComponents.IconButton(CoreSettings.Config.AppMute ? Icon.ToggleAudioOff : Icon.ToggleAudioOn, ControlSize, CoreSettings.Config.AppMute ? CustomComponents.ButtonStates.NeedsAttention : CustomComponents.ButtonStates.Default )) { AudioEngine.SetGlobalMute(!CoreSettings.Config.AppMute); } // ToggleHover { ImGui.SameLine(); Icon icon; string hoverModeTooltip; string hoverModeAdditionalTooltip = null; CustomComponents.ButtonStates state = CustomComponents.ButtonStates.Emphasized; switch (UserSettings.Config.HoverMode) { case UserSettings.GraphHoverModes.Disabled: state = CustomComponents.ButtonStates.Default; icon = Icon.HoverPreviewDisabled; hoverModeTooltip = "No preview images on hover"; break; case UserSettings.GraphHoverModes.Live: icon = Icon.HoverPreviewPlay; hoverModeTooltip = "Live Hover Preview - Render explicit thumbnail image."; hoverModeAdditionalTooltip = "This can interfere with the rendering of the current output."; break; default: icon = Icon.HoverPreviewSmall; hoverModeTooltip = "Last - Show the current state of the operator."; hoverModeAdditionalTooltip = "This can be outdated if operator is not require for current output."; break; } if (CustomComponents.IconButton(icon, ControlSize, state)) { UserSettings.Config.HoverMode = (UserSettings.GraphHoverModes)(((int)UserSettings.Config.HoverMode + 1) % Enum.GetNames(typeof(UserSettings.GraphHoverModes)).Length); } CustomComponents.TooltipForLastItem(hoverModeTooltip, hoverModeAdditionalTooltip); } if (FrameStats.Last.HasAnimatedParameters) { // Lock all animated parameters ImGui.SameLine(); var state = UserSettings.Config.AutoPinAllAnimations ? CustomComponents.ButtonStates.Activated : CustomComponents.ButtonStates.Default; if (CustomComponents.IconButton(Icon.PinParams, ControlSize, state) || UserActions.ToggleAnimationPinning.Triggered()) { UserSettings.Config.AutoPinAllAnimations = !UserSettings.Config.AutoPinAllAnimations; if (!UserSettings.Config.AutoPinAllAnimations) { timeLineCanvas.DopeSheetArea.PinnedParametersHashes.Clear(); } } CustomComponents.TooltipForLastItem("Keep animated parameters visible", "This can be useful when align animations between multiple operators. Toggle again to clear the visible animations.\n\n" + UserActions.ToggleAnimationPinning.ListShortcuts() ); } ImGui.SameLine(); } private static double _lastPlaybackStartTime; // beat grid optimizations private static float _lastUiScaleFactor = -1f; private static float _cachedCellSize; private static Vector2 _cachedGridSize; private static float CellSize { get { if (!(Math.Abs(_lastUiScaleFactor - T3Ui.UiScaleFactor) > 0.001f)) return _cachedCellSize; _lastUiScaleFactor = T3Ui.UiScaleFactor; _cachedCellSize = 4f * T3Ui.UiScaleFactor; _cachedGridSize = new Vector2(_cachedCellSize * 4 * 0.5f); return _cachedCellSize; } } private static Vector2 GridSize { get { // Trigger cache update if needed _ = CellSize; return _cachedGridSize; } } // end of beat grid optimizations private static float StandardWidth => 100f * T3Ui.UiScaleFactor; public static Vector2 ControlSize => new Vector2(35, 28) * T3Ui.UiScaleFactor; private static Vector2 DopeCurve => new Vector2(95, 28) * T3Ui.UiScaleFactor; private static Vector2 SyncButton => new Vector2(45, 28) * T3Ui.UiScaleFactor; private static readonly DataSetViewCanvas _dataSetView = new() { ShowInteraction = false, MaxTreeLevel = 0, }; public static bool JogDial(string label, ref double delta, Vector2 size) { ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(1, 0.5f)); var isActive = ImGui.Button(label + "###dummy", size); ImGui.PopStyleVar(); var io = ImGui.GetIO(); if (ImGui.IsItemActive()) { var center = (ImGui.GetItemRectMin() + ImGui.GetItemRectMax()) * 0.5f; ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); ImGui.GetForegroundDrawList().AddCircle(center, 100, UiColors.Gray, 50); isActive = true; var pLast = io.MousePos - io.MouseDelta - center; var pNow = io.MousePos - center; var aLast = Math.Atan2(pLast.X, pLast.Y); var aNow = Math.Atan2(pNow.X, pNow.Y); delta = aLast - aNow; if (delta > 1.5) { delta -= 2 * Math.PI; } else if (delta < -1.5) { delta += 2 * Math.PI; } } return isActive; } }