From 297c52f379503da187d84a22dcc9402f6bae50d1 Mon Sep 17 00:00:00 2001 From: Thomas Mathieson Date: Sat, 16 May 2026 22:09:51 +0100 Subject: [PATCH 1/4] Initial work in supporting group cues: - Showfile converter now uses the CueFactory to create cues - Added group cue model - Added group cue expander icon - Improved FastReverseIterator - TemporaryList can now be constructed from a source enumerable with a specific capacity - Implemented CueList type which handles a lot of the logic for a hierarchical cue list with groups that can be expanded and collapsed - CueViewModel mainViewModel is now readonly & added logic for parent cues - Started work implementing logic for group cues - Started work on implementing methods to group and ungroup cues - Updated existing main view model code to support hierarchical CueLists - Added expander knob control --- QPlayer/Models/ShowFile.cs | 13 +- QPlayer/Models/ShowFileConverter.cs | 11 +- QPlayer/Resources/Icons/ConvertedIcons.xaml | Bin 52106 -> 53514 bytes .../Resources/Icons/angle-down-solid-full.svg | 1 + QPlayer/Utilities/ExtensionMethods.cs | 4 + QPlayer/Utilities/FastReverseIterator.cs | 26 +- QPlayer/Utilities/FixedArrayPool.cs | 17 + QPlayer/Utilities/TemporaryList.cs | 20 +- QPlayer/Utilities/ValueConverters.cs | 24 + QPlayer/ViewModels/CueList.cs | 969 ++++++++++++++++++ QPlayer/ViewModels/CueViewModel.cs | 74 +- QPlayer/ViewModels/GroupCueViewModel.cs | 95 +- QPlayer/ViewModels/MainViewModel.CueStack.cs | 177 +++- .../MainViewModel.FileManagement.cs | 36 +- QPlayer/ViewModels/MainViewModel.Transport.cs | 1 + QPlayer/ViewModels/MainViewModel.cs | 2 +- QPlayer/ViewModels/SoundCueViewModel.cs | 21 +- QPlayer/ViewModels/StopCueViewModel.cs | 2 +- QPlayer/ViewModels/VolumeCueViewModel.cs | 2 +- QPlayer/Views/CueDataControl.xaml | 18 +- QPlayer/Views/CueDataControl.xaml.cs | 21 +- QPlayer/Views/ExpanderKnob.xaml | 18 + QPlayer/Views/ExpanderKnob.xaml.cs | 61 ++ QPlayer/Views/MainWindow.xaml.cs | 2 +- 24 files changed, 1492 insertions(+), 123 deletions(-) create mode 100644 QPlayer/Resources/Icons/angle-down-solid-full.svg create mode 100644 QPlayer/ViewModels/CueList.cs create mode 100644 QPlayer/Views/ExpanderKnob.xaml create mode 100644 QPlayer/Views/ExpanderKnob.xaml.cs diff --git a/QPlayer/Models/ShowFile.cs b/QPlayer/Models/ShowFile.cs index c52c4ba..7ee3f11 100644 --- a/QPlayer/Models/ShowFile.cs +++ b/QPlayer/Models/ShowFile.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Drawing; using System.Numerics; namespace QPlayer.Models; @@ -91,6 +90,13 @@ public enum TriggerMode AfterLast } +public enum GroupTriggerMode +{ + Next, + All, + Shuffle +} + public record Cue { //public CueType type; @@ -110,7 +116,12 @@ public record Cue public record GroupCue : Cue { + // TODO: Since we now have a cue factory, these constructors should be made internal public GroupCue() : base() { } + + public List cues = []; + public GroupTriggerMode groupTrigger; + public bool isCollapsed; } public record DummyCue : Cue diff --git a/QPlayer/Models/ShowFileConverter.cs b/QPlayer/Models/ShowFileConverter.cs index 58a8c02..1b2d103 100644 --- a/QPlayer/Models/ShowFileConverter.cs +++ b/QPlayer/Models/ShowFileConverter.cs @@ -312,16 +312,7 @@ private static Cue LoadCueSafe(JsonElement json) case "$type": if (field.Value.ValueKind == JsonValueKind.String) { - cue = field.Value.GetString() switch - { - nameof(DummyCue) => new DummyCue(), - nameof(GroupCue) => new GroupCue(), - nameof(SoundCue) => new SoundCue(), - nameof(StopCue) => new StopCue(), - nameof(TimeCodeCue) => new TimeCodeCue(), - nameof(VolumeCue) => new VolumeCue(), - _ => cue, - }; + cue = CueFactory.CreateCue(field.Value.GetString() ?? string.Empty) ?? cue; } goto CueCreated; //case "type": diff --git a/QPlayer/Resources/Icons/ConvertedIcons.xaml b/QPlayer/Resources/Icons/ConvertedIcons.xaml index ecfc334d27af1cce2493b04ad1321ce39e4eb0ea..c68ccd06324b0c27facc3bcad38b128ee0944582 100644 GIT binary patch delta 638 zcmZ{ize)o^5XL`9F9{ZD4+L#wxtsgv)mm8G11JO};GZVM((?LiM7FT8@(HxCu(nKR zVeKRM2!6AhJr+%hh2i^VelxSX{o|;A9=*&~cKjieC2p|56)tdzTiju3@85F!xtDZ; z1~m){9P;EiVqD>vQA)?~s9qvtoH5RwV<7+Zf|?;K=CrI#2VLj}z7k8A&Vht2E7u@# zt!S)uZEaYjdJNJr3{S%08ag3gT&(#8iD3aDO>?|n4hsvq0BQ--B;Bxf$u%JvOLVJ> zy3o<41#c%?=je?Dc;oVQ9BuShSE>#ibI3nqc`+3DZ9OQ~{q2Zr5QF@6)@Vub&!`RD uGunsO^W8Z%J^a*;-%srI>DLzjz^0SM7H?{Q`W^fI)NcGz>h9aw%KQR%^klIB delta 13 VcmeBL#N0KVc|*+U$#*X30RSs72ABW< diff --git a/QPlayer/Resources/Icons/angle-down-solid-full.svg b/QPlayer/Resources/Icons/angle-down-solid-full.svg new file mode 100644 index 0000000..6f1684b --- /dev/null +++ b/QPlayer/Resources/Icons/angle-down-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/QPlayer/Utilities/ExtensionMethods.cs b/QPlayer/Utilities/ExtensionMethods.cs index 60a4e60..ffd1be5 100644 --- a/QPlayer/Utilities/ExtensionMethods.cs +++ b/QPlayer/Utilities/ExtensionMethods.cs @@ -9,7 +9,9 @@ using System.Linq; using System.Net; using System.Numerics; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using System.Windows; @@ -226,6 +228,8 @@ public static void AddRange(this PointCollection collection, IEnumerable /// public static TemporaryList ToTempList(this IEnumerable values) => new(values); + /// + public static TemporaryList ToTempList(this IEnumerable values, int capacity) => new(values, capacity); /// /// Reverses the given enumerable efficiently. This may require enumerating the entire collection. diff --git a/QPlayer/Utilities/FastReverseIterator.cs b/QPlayer/Utilities/FastReverseIterator.cs index b2c34e5..a6a8378 100644 --- a/QPlayer/Utilities/FastReverseIterator.cs +++ b/QPlayer/Utilities/FastReverseIterator.cs @@ -6,16 +6,28 @@ namespace QPlayer.Utilities; -public readonly struct FastReverseEnumerable(IEnumerable source) : IEnumerable +public readonly struct FastReverseEnumerable(IEnumerable source) : ICollection, IReadOnlyCollection { + /// + /// Accessing this member may require enumerating the source. + /// + public int Count => source.Count(); + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() => new FastReverseIterator(source); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public bool Contains(T item) => throw new InvalidOperationException(); + public void CopyTo(T[] array, int arrayIndex) => throw new InvalidOperationException(); + public void Add(T item) => throw new InvalidOperationException(); + public void Clear() => throw new InvalidOperationException(); + public bool Remove(T item) => throw new InvalidOperationException(); } public struct FastReverseIterator : IEnumerator { - private readonly IList? source; + private readonly IReadOnlyList? source; private TemporaryList tempList; private readonly int len; private int pos; @@ -24,12 +36,18 @@ public struct FastReverseIterator : IEnumerator readonly object? IEnumerator.Current => Current; + public readonly int Count => source?.Count ?? 0; + public FastReverseIterator(IEnumerable source) { - if (source is IList list) + /*if (source is IList list) { this.source = list; - len = pos = list.Count; + }*/ + if (source is IReadOnlyList listRO) + { + this.source = listRO; + len = pos = listRO.Count; } else { diff --git a/QPlayer/Utilities/FixedArrayPool.cs b/QPlayer/Utilities/FixedArrayPool.cs index f384263..507453b 100644 --- a/QPlayer/Utilities/FixedArrayPool.cs +++ b/QPlayer/Utilities/FixedArrayPool.cs @@ -18,6 +18,15 @@ public class FixedArrayPool : ArrayPool private readonly int arraySize; private readonly int maxCount; + /// + /// Gets the size of array that this pool stores. + /// + public int ArraySize => arraySize; + /// + /// Gets the maximum number of arrays that this pool stores. + /// + public int MaxCount => maxCount; + public FixedArrayPool(int arraySize, int initialNumber, int maxCount) { this.arraySize = arraySize; @@ -27,6 +36,14 @@ public FixedArrayPool(int arraySize, int initialNumber, int maxCount) arrays[i] = new T[arraySize]; } + /// + /// Rents an array from the pool with a size of . Throws an + /// if the requested size is bigger than + /// the . The returned array must be returned to the pool using + /// . + /// + /// + /// public override T[] Rent(int minimumLength) { ArgumentOutOfRangeException.ThrowIfGreaterThan(minimumLength, arraySize); diff --git a/QPlayer/Utilities/TemporaryList.cs b/QPlayer/Utilities/TemporaryList.cs index d11349d..a63cf71 100644 --- a/QPlayer/Utilities/TemporaryList.cs +++ b/QPlayer/Utilities/TemporaryList.cs @@ -18,7 +18,7 @@ namespace QPlayer.Utilities; #if NET8_0_OR_GREATER [CollectionBuilder(typeof(TemporaryListBuilder), nameof(TemporaryListBuilder.Create))] #endif -public struct TemporaryList : IList, IDisposable +public struct TemporaryList : IList, IReadOnlyList, IDisposable { private ArrayPool? arrayPool; private T[]? items; @@ -57,19 +57,19 @@ public TemporaryList(ReadOnlySpan items) : this(items.Length, null) items.CopyTo(this.items); } - public TemporaryList(IEnumerable items) + public TemporaryList(IEnumerable items, int capacity = 8) { switch (items) { case T[] array: { - Initialise(array.Length); + Initialise(Math.Max(capacity, array.Length)); Array.Copy(array, this.items!, array.Length); break; } case ICollection collection: { - Initialise(collection.Count); + Initialise(Math.Max(capacity, collection.Count)); foreach (var item in collection) Add(item); break; @@ -78,10 +78,10 @@ public TemporaryList(IEnumerable items) { #if NET10_0_OR_GREATER if (items.TryGetNonEnumeratedCount(out var len)) - Initialise(len); + Initialise(Math.Max(capacity, len)); else #endif - Initialise(8); + Initialise(capacity); foreach (var item in items) Add(item); break; @@ -95,7 +95,7 @@ public TemporaryList(IEnumerable items) private void Initialise(int capacity = 0, ArrayPool? arrayPool = null) { this.arrayPool = arrayPool ?? ArrayPool.Shared; - items = this.arrayPool.Rent(capacity); + items = capacity > 0 ? this.arrayPool.Rent(capacity) : []; } #if !NETSTANDARD @@ -161,11 +161,15 @@ public void AddRange(IEnumerable items) version++; break; case ICollection collection: - EnsureCapacity(collection.Count); + EnsureCapacity(count + collection.Count); foreach (var item in collection) Add(item); break; default: +#if NET5_0_OR_GREATER + if (items.TryGetNonEnumeratedCount(out int enumCount)) + EnsureCapacity(count + enumCount); +#endif foreach (var item in items) Add(item); break; diff --git a/QPlayer/Utilities/ValueConverters.cs b/QPlayer/Utilities/ValueConverters.cs index c92e620..52bcb98 100644 --- a/QPlayer/Utilities/ValueConverters.cs +++ b/QPlayer/Utilities/ValueConverters.cs @@ -7,6 +7,7 @@ using System.Windows.Data; using System.Windows; using QPlayer.Models; +using QPlayer.ViewModels; namespace QPlayer.Utilities; @@ -182,3 +183,26 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu return invert ^ (Visibility)value == Visibility.Visible; } } + +[ValueConversion(typeof(CueViewModel), typeof(Thickness))] +public class ParentIndentConverter : IValueConverter +{ + public double IndentSize { get; set; } = 10; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) + return default(Thickness); + + int i = 0; + var parent = value as CueViewModel; + while (parent != null) + { + i++; + parent = parent.Parent; + } + return new Thickness(i * IndentSize, 0, 0, 0); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => DependencyProperty.UnsetValue; +} diff --git a/QPlayer/ViewModels/CueList.cs b/QPlayer/ViewModels/CueList.cs new file mode 100644 index 0000000..13c8370 --- /dev/null +++ b/QPlayer/ViewModels/CueList.cs @@ -0,0 +1,969 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using QPlayer.Models; +using QPlayer.Utilities; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace QPlayer.ViewModels; + +/// +/// The base class for a hierarchical list of cues. +/// +public class AbstractCueList : ObservableObject, IReadOnlyCollection +{ + protected internal readonly List rootCueList = []; + protected internal List? boundModel = []; + + /// + /// The number of cues at the root of the cue list (ie: not counting sub-cues) + /// + public int Count => rootCueList.Count; + + /// + /// Gets a root cue by index. + /// + /// + /// + public CueViewModel this[int index] + { + get => rootCueList[index]; + } + + /// + /// Inserts a cue at the given index in the root cue list. + /// + /// + /// + /// + protected internal bool Insert(int index, CueViewModel item) + { + var list = rootCueList; + var model = boundModel; + if (index < 0 || index > list.Count) + return false; + if (index == list.Count) + { + list.Add(item); + model?.Add(item.BoundModel!); + } + else + { + list.Insert(index, item); + model?.Insert(index, item.BoundModel!); + } + //CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Add, item, index)); + //OnPropertyChanged(nameof(Count)); + + return true; + } + + /// + /// Inserts a range of ordered cues at the specified index in the root cue list. + /// + /// + /// + /// + protected internal bool Insert(int index, IEnumerable items) + { + var list = rootCueList; + var model = boundModel; + if (index < 0 || index > list.Count) + return false; + + list.InsertRange(index, items); + model?.InsertRange(index, items.Select(x => x.BoundModel!)); + + //CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Add, item, index)); + //OnPropertyChanged(nameof(Count)); + + return true; + } + + /// + /// Removes a cue by index from the root cue list. + /// + /// + /// The cue that was removed or if the index was invalid. + protected internal CueViewModel? Remove(int index) + { + var list = rootCueList; + var model = boundModel; + if (index < 0 || index >= list.Count) + return null; + + var item = list[index]; + + list.RemoveAt(index); + model?.RemoveAt(index); + + //CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Remove, item, index)); + //OnPropertyChanged(nameof(Count)); + + return item; + } + + /// + /// Removes a range of cues by index from the root cue list. + /// + /// The enumerable of indices to remove. + /// Whether the list of removed cue instances should be collected and returned. + /// If the is already sorted in ascending order, + /// then this skips needing to copy and sort the indices before use. + /// An array of cues that were removed or an empty array if none were removed. + /// Note, that group cues are not enumerated in this array. + protected internal CueViewModel[] Remove(IEnumerable indices, bool returnRemoved = true, bool isSorted = false) + { + var list = rootCueList; + var model = boundModel; + + using TemporaryList results = default; + + var inds = indices; + TemporaryList tl = default; + if (!isSorted) + { + tl = indices.ToTempList(); + tl.Sort(); + inds = tl; + } + inds = inds.FastReverse(); + + foreach (var ind in indices) + { + if (ind < 0 || ind > list.Count) + continue; + + if (returnRemoved) + results.Add(list[ind]); + + list.RemoveAt(ind); + model?.RemoveAt(ind); + } + + tl.Dispose(); + + if (returnRemoved) + return results.ToArray(); + else + return []; + } + + protected internal virtual void Clear() + { + rootCueList.Clear(); + boundModel?.Clear(); + } + + /// + /// Recursively enumerates all root cues and sub cues in this cue list in depth-first order. + /// + /// An iterator which enumerates each cue in order. + protected internal IEnumerable EnumerateAll() + { + foreach (var cue in rootCueList) + { + yield return cue; + if (cue is GroupCueViewModel group) + { + var children = group.Cues.EnumerateAll(); + foreach (var child in children) + yield return child; + } + } + } + + /// + /// Enumerates all the visible cues (and subcues) in this cue list. + /// Cues belonging to collapsed groups will not be enumerated. + /// See also for more efficient visual + /// cue enumeration. + /// + /// + protected internal IEnumerable EnumerateVisible() + { + foreach (var cue in rootCueList) + { + yield return cue; + if (cue is GroupCueViewModel group && !group.IsCollapsed) + { + var children = group.Cues.EnumerateVisible(); + foreach (var child in children) + yield return child; + } + } + } + + /// + /// Enumerates all the visible cues (and subcues) in this cue list as well as their . + /// + /// The group cue which is the parent of this or . + /// + protected internal IEnumerable<(CueViewModel cue, CuePosition pos)> EnumerateVisiblePositions(GroupCueViewModel? parent = null) + { + int i = 0; + foreach (var cue in rootCueList) + { + yield return (cue, new(i++, parent)); + if (cue is GroupCueViewModel group && !group.IsCollapsed) + { + var children = group.Cues.EnumerateVisiblePositions(group); + foreach (var child in children) + yield return child; + } + } + } + + public IEnumerator GetEnumerator() => rootCueList.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => rootCueList.GetEnumerator(); + + /// + /// Binds this to the given model list. Changes to this list will + /// automatically be synchronised to the bound model. + /// + /// + public void Bind(List model) + { + boundModel = model; + } + + /// + /// Resynchronises all the cues in this to the bound model + /// (set with ). + /// + public void SyncToModel() + { + if (boundModel == null) + return; + + CollectionsMarshal.SetCount(boundModel, rootCueList.Count); + var dst = CollectionsMarshal.AsSpan(boundModel); + for (int i = 0; i < rootCueList.Count; i++) + { + CueViewModel? cue = rootCueList[i]; + dst[i] = cue.BoundModel!; + } + } + + /// + /// Syncronises the contents of this CueList with the cue models in the bound model + /// (set via ). + /// + /// + public virtual void SyncFromModel(Func convertCue) + { + if (boundModel == null) + return; + + CollectionsMarshal.SetCount(rootCueList, boundModel.Count); + var dst = CollectionsMarshal.AsSpan(rootCueList); + for (int i = 0; i < boundModel.Count; i++) + { + var cue = boundModel[i]; + var cueVm = convertCue(cue); + if (cueVm == null) + continue; + dst[i] = cueVm; + if (cueVm is GroupCueViewModel group) + group.Cues.SyncFromModel(convertCue); + } + } + + public delegate ValueTask ConvertCueDelegate(Cue src); + + /// + public virtual async Task SyncFromModelAsync(ConvertCueDelegate convertCue) + { + if (boundModel == null) + return; + + CollectionsMarshal.SetCount(rootCueList, boundModel.Count); + for (int i = 0; i < boundModel.Count; i++) + { + var cue = boundModel[i]; + var cueVm = await convertCue(cue); + if (cueVm == null) + continue; + rootCueList[i] = cueVm; + if (cueVm is GroupCueViewModel group) + await group.Cues.SyncFromModelAsync(convertCue); + } + } + + /// + /// Returns an for the contents of this cue list. + /// + /// + /// + public virtual IEnumerable Enumerate(EnumerationMode mode) + { + return mode switch + { + EnumerationMode.Root => rootCueList, + EnumerationMode.Visible => EnumerateVisible(), + EnumerationMode.All => EnumerateAll(), + _ => EnumerateAll() + }; + } + + public IEnumerator GetEnumerator(EnumerationMode mode) => Enumerate(mode).GetEnumerator(); + + /// + /// Represents the hierarchical position of a cue in the cue stack. + /// + /// The index of the cue in the stack (or sub-stack). + /// The group cue this cue belongs to or if this cue is part of the root cue list. + public readonly struct CuePosition(int index, GroupCueViewModel? group) + { + public readonly int index = index; + public readonly GroupCueViewModel? group = group; + } + + /// + /// A comparer which orders cues by their . + /// + public readonly struct QIDComparer : IComparer + { + public readonly int Compare(CueViewModel? x, CueViewModel? y) + { + if (x == null) + return -1; + if (y == null) + return 1; + + return x.QID.CompareTo(y.QID); + } + } + + public enum EnumerationMode + { + /// + /// All cues and sub-cues (cues belonging to groups) will enumerated recursively. + /// + All, + /// + /// Only the visible cues (ie: all cues, except those which belong to collapsed groups) will be enumerated. + /// + Visible, + /// + /// Only the cues in the top-level of the cue hierarchy (ie: don't belong to a group) will be enumerated. + /// + Root + } +} + +/// +/// A hierarchical cue list which belongs to a group cue. +/// +public class SubCueList : AbstractCueList +{ + +} + +/// +/// A hierarchical cue list. +/// +public class CueList : AbstractCueList, INotifyCollectionChanged +{ + private readonly List visualCues = []; + private readonly List visualPositions = []; + private readonly Dictionary positionCache = []; + private readonly VisualCueList visualCueList; + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + private static readonly PropertyChangedEventArgs CountPropertyChanged = new(nameof(Count)); + private static readonly PropertyChangedEventArgs IndexerPropertyChanged = new("Item[]"); + private static readonly NotifyCollectionChangedEventArgs ResetCollectionChanged = new(NotifyCollectionChangedAction.Reset); + + /// + /// An enumerable cue list of only the visible cues in the cue list. + /// + public VisualCueList VisualCues => visualCueList; + + public CueList() : base() + { + visualCueList = new(this); + } + + private void OnCollectionChanged() + { + CollectionChanged?.Invoke(this, ResetCollectionChanged); + OnPropertyChanged(CountPropertyChanged); + OnPropertyChanged(IndexerPropertyChanged); + } + + private void OnCollectionChanged(NotifyCollectionChangedAction action, object? changedItem, int index) + { + CollectionChanged?.Invoke(this, new(action, changedItem, index)); + OnPropertyChanged(CountPropertyChanged); + OnPropertyChanged(IndexerPropertyChanged); + } + + private void OnCollectionChanged(NotifyCollectionChangedAction action, object[]? changedItems) + { + CollectionChanged?.Invoke(this, new(action, changedItems)); + OnPropertyChanged(CountPropertyChanged); + OnPropertyChanged(IndexerPropertyChanged); + } + + internal CueViewModel GetVisualCue(int index) => visualCues[index]; + + /// + /// Finds the index of the specified cue in the visual cue list, or -1 if the cue does not exist in the visual list. + /// + /// + /// + public int FindVisualIndex(CueViewModel cue) + { + /*if (assumeSorted) + return Math.Max(-1, visualCues.BinarySearch(item, default(QIDComparer))); + else + return visualCues.IndexOf(item);*/ + + if (positionCache.TryGetValue(cue, out var x)) + return x.visPos; + return -1; + } + + /// + /// Finds the of the specified cue in the cue list. + /// + /// + /// + /// if the cue was found. + public bool Find(CueViewModel cue, out CuePosition position) + { + if (positionCache.TryGetValue(cue, out var x)) + { + position = x.pos; + return true; + } + position = default; + return false; + } + + /// + /// Finds the of the specified cue by it's visual index in the cue list. + /// + /// + /// + /// if the cue was found. + public bool Find(int visualIndex, out CuePosition position) + { + position = default; + if (visualIndex < 0 || visualIndex >= visualCues.Count) + return false; + position = visualPositions[visualIndex]; + return true; + } + + private IEnumerable GetCues(IEnumerable positions) + { + foreach (var pos in positions) + { + var src = pos.group?.Cues?.rootCueList ?? rootCueList; + if (pos.index < 0 || pos.index >= src.Count) + continue; + yield return src[pos.index]; + } + } + + private IEnumerable GetPositions(IEnumerable cues) + { + foreach (var cue in cues) + { + if (Find(cue, out var pos)) + yield return pos; + } + } + + private IEnumerable GetPositions(IEnumerable visualInds) + { + return visualInds.Select(ind => visualPositions[ind]); + } + + private void OnVisualGroupCollapsed(GroupCueViewModel group, bool isCollapsed) + { + var startInd = FindVisualIndex(group); + if (startInd == -1) + { + MainViewModel.Log($"Tried to collapse/expand group which is not in the cue list!", MainViewModel.LogLevel.Warning); + return; + } + startInd++; + + if (isCollapsed) + { + // Remove this group's contents from the visual list + var toRemove = group.Cues.EnumerateVisible().Count(); + // Unsubscribe from subgroups and remove from cache + for (int i = startInd; i < startInd + toRemove; i++) + { + var cue = visualCues[i]; + if (cue is GroupCueViewModel subgroup) + subgroup.OnCollapse -= OnVisualGroupCollapsed; + positionCache.Remove(cue); + } + // Remove from visual list + visualCues.RemoveRange(startInd, toRemove); + visualPositions.RemoveRange(startInd, toRemove); + } + else + { + // Collect the visible subcues + using var subcues = group.Cues.EnumerateVisiblePositions(group).ToTempList(); + using var subcueInst = subcues.Select(x => x.cue).ToTempList(); + using var subcuePos = subcues.Select(x => x.pos).ToTempList(); + + // Insert them into the visible list and subscribe to events + visualCues.InsertRange(startInd, subcueInst); + visualPositions.InsertRange(startInd, subcuePos); + int i = startInd; + foreach (var (cue, pos) in subcues) + { + positionCache.Add(cue, (pos, i)); + if (cue is GroupCueViewModel subgroup) + subgroup.OnCollapse += OnVisualGroupCollapsed; + i++; + } + } + } + + protected internal CueViewModel? Delete(int visualIndex) + { + if (!Find(visualIndex, out var pos)) + return null; + + return Delete(pos); + } + protected internal bool Delete(CueViewModel cue) + { + if (!Find(cue, out var pos)) + return false; + + return Delete(pos) != null; + } + protected internal CueViewModel[] Delete(IEnumerable cues, bool collectResults = true) => Delete(GetPositions(cues), collectResults); + + protected internal CueViewModel[] Delete(IEnumerable visualIndices, bool collectResults = true) => Delete(GetPositions(visualIndices), collectResults); + + protected internal CueViewModel[] Delete(IEnumerable cues, bool collectResults = true, bool needsSorting = true) + { + // using var deleted = GetCues(cues).ToTempList(); + + using var cuesList = cues.ToTempList(); + // using var removedInds = new TemporaryList(); + var removedItems = new TemporaryList(); + + if (!needsSorting) + cuesList.Sort(default(CuePositionComparer)); + + ref var removedItemsRef = ref (collectResults ? ref removedItems : ref Unsafe.NullRef>()); + foreach (var pos in cuesList.FastReverse()) + { + AbstractCueList list = this; + if (pos.group != null) + list = pos.group.Cues; + + if (list.Remove(pos.index) is not CueViewModel removed) + continue; + + if (collectResults) + removedItems.Add(removed); + + if (removed is GroupCueViewModel group && !group.IsCollapsed) + { + RemoveGroupCueContents(group, ref removedItemsRef); + group.OnCollapse -= OnVisualGroupCollapsed; + } + + // Update the visual list + if (positionCache.Remove(removed, out var oldPos) && oldPos.visPos != -1) + { + visualCues.RemoveAt(oldPos.visPos); + visualPositions.RemoveAt(oldPos.visPos); + // removedInds.Add(oldPos.visPos); + } + } + + if (collectResults) + { + var removedArr = removedItems.ToArray(); + removedItems.Dispose(); + OnCollectionChanged(NotifyCollectionChangedAction.Remove, removedArr); + return removedArr; + } + else + { + OnCollectionChanged(); + return []; + } + } + + protected internal CueViewModel? Delete(CuePosition cue) + { + AbstractCueList list = this; + if (cue.group != null) + list = cue.group.Cues; + + if (list.Remove(cue.index) is not CueViewModel removed) + return null; + + if (removed is GroupCueViewModel group && !group.IsCollapsed) + { + RemoveGroupCueContents(group, ref Unsafe.NullRef>()); + group.OnCollapse -= OnVisualGroupCollapsed; + } + + // Update the visual list + if (positionCache.Remove(removed, out var oldPos) && oldPos.visPos != -1) + { + visualCues.RemoveAt(oldPos.visPos); + visualPositions.RemoveAt(oldPos.visPos); + + OnCollectionChanged(NotifyCollectionChangedAction.Remove, removed, oldPos.visPos); + } + + return removed; + } + + private void RemoveGroupCueContents(GroupCueViewModel group, ref TemporaryList removed) + { + using var visible = group.Cues.EnumerateVisible().ToTempList(); + foreach (var item in visible.FastReverse()) + { + if (positionCache.Remove(item, out var oldPos) && oldPos.visPos != -1) + { + visualCues.RemoveAt(oldPos.visPos); + visualPositions.RemoveAt(oldPos.visPos); + // removedInds.Add(oldPos.visPos); + } + + if (item is GroupCueViewModel subgroup) + subgroup.OnCollapse -= OnVisualGroupCollapsed; + } + + if (!Unsafe.IsNullRef(ref removed)) + removed.AddRange(visible.FastReverse()); + } + + protected internal int[] Insert(IEnumerable visualIndices, IEnumerable cues, bool collectResults = true) + { + return Insert(visualIndices.Select(x => visualPositions[x]), cues, collectResults); + } + + protected internal int Insert(int visualIndex, IEnumerable cues, bool collectResults = true) + { + if (visualIndex < 0 || visualIndex >= visualPositions.Count) + return -2; + return Insert(visualPositions[visualIndex], cues, collectResults); + } + + protected internal new int Insert(int visualIndex, CueViewModel cue) + { + if (visualIndex < 0 || visualIndex >= visualPositions.Count) + return -2; + return Insert(visualPositions[visualIndex], cue); + } + + protected internal int[] Insert(IEnumerable positions, IEnumerable cues, bool collectResults = true) + { + using var added = new TemporaryList(); + using var addedInds = new TemporaryList(); + + foreach (var (cue, pos) in cues.Zip(positions)) + { + AbstractCueList list = this; + if (pos.group != null) + list = pos.group.Cues; + + if (!list.Insert(pos.index, cue)) + continue; + + // Compute visPos + int visPos = ComputeNewVisPos(pos); + // Update cache + positionCache.Add(cue, (pos, visPos)); + + // Update the visual list + if (visPos != -1) + { + visualCues.Insert(visPos, cue); + visualPositions.Insert(visPos, pos); + if (collectResults) + { + added.Add(cue); + addedInds.Add(visPos); + } + if (cue is GroupCueViewModel group) + { + var res = added; + if (!collectResults) + res = []; + int n = res.Count; + + InsertGroupCueContents(visPos + 1, group, ref res); + addedInds.AddRange(Enumerable.Range(visPos + 1, res.Count - n)); + + if (!collectResults) + res.Dispose(); + } + } + } + if (collectResults) + { + OnCollectionChanged(NotifyCollectionChangedAction.Add, added.ToArray()); + return addedInds.ToArray(); + } + + OnCollectionChanged(); + return []; + } + + protected internal int Insert(CuePosition pos, IEnumerable cues, bool collectResults = true) + { + var added = new TemporaryList(); + + int firstVisPos = -2; + foreach (var cue in cues) + { + AbstractCueList list = this; + if (pos.group != null) + list = pos.group.Cues; + + if (!list.Insert(pos.index, cue)) + continue; + + // Compute visPos + int visPos = ComputeNewVisPos(pos); + // Update cache + positionCache.Add(cue, (pos, visPos)); + if (firstVisPos == -2) + firstVisPos = visPos; + + // Update the visual list + if (visPos != -1) + { + visualCues.Insert(visPos, cue); + visualPositions.Insert(visPos, pos); + if (collectResults) + added.Add(cue); + + if (cue is GroupCueViewModel group) + InsertGroupCueContents(visPos + 1, group, ref collectResults ? ref added : ref Unsafe.NullRef>()); + } + + // Increment the position + pos = new(pos.index + 1, pos.group); + } + if (collectResults) + OnCollectionChanged(NotifyCollectionChangedAction.Add, added.ToArray(), firstVisPos); + else + OnCollectionChanged(); + + added.Dispose(); + return firstVisPos; + } + + /// + /// Inserts a cue into the cue stack in the specified position. + /// + /// + /// + /// The visual index of the newly inserted cue or -1 if it's currently hidden, or -2 if the cue couldn't be inserted. + protected internal int Insert(CuePosition pos, CueViewModel cue) + { + AbstractCueList list = this; + if (pos.group != null) + list = pos.group.Cues; + + if (!list.Insert(pos.index, cue)) + return -2; + + // Compute visPos + int visPos = ComputeNewVisPos(pos); + + // Update cache + positionCache.Add(cue, (pos, visPos)); + + // Update the visual list + if (visPos != -1) + { + visualCues.Insert(visPos, cue); + visualPositions.Insert(visPos, pos); + + if (cue is GroupCueViewModel group) + { + TemporaryList added = [cue]; + InsertGroupCueContents(visPos + 1, group, ref added); + OnCollectionChanged(NotifyCollectionChangedAction.Add, added.ToArray()); + added.Dispose(); + } + else + { + OnCollectionChanged(NotifyCollectionChangedAction.Add, cue, visPos); + } + } + + return visPos; + } + + private int ComputeNewVisPos(CuePosition pos) + { + int visPos = -1; + if (pos.index != 0) + { + AbstractCueList list = (pos.group?.Cues as AbstractCueList) ?? this; + var prev = list[pos.index - 1]; + int prevPos = FindVisualIndex(prev); + if (prevPos != -1) + visPos = prevPos + 1; + } + else + { + var prev = pos.group; + if (prev == null) + visPos = pos.index; + else + { + int prevPos = FindVisualIndex(prev); + if (prevPos != -1) + visPos = prevPos + 1; + } + } + return visPos; + } + + private void InsertGroupCueContents(int visualIndex, GroupCueViewModel group, ref TemporaryList inserted) + { + using var visible = group.Cues.EnumerateVisiblePositions().ToTempList(); + foreach (var (cue, pos) in visible) + { + positionCache.Add(cue, (pos, visualIndex)); + + // Update the visual list + visualCues.Insert(visualIndex, cue); + visualPositions.Insert(visualIndex, pos); + + if (cue is GroupCueViewModel subgroup) + subgroup.OnCollapse += OnVisualGroupCollapsed; + } + + if (!Unsafe.IsNullRef(ref inserted)) + inserted.AddRange(visible.Select(x => x.cue)); + } + + private void ResetVisualCache() + { + // Unsubscribe old event listeners + foreach (var oldGroup in visualCues.OfType()) + oldGroup.OnCollapse -= OnVisualGroupCollapsed; + + // Clear the visual cache + visualCues.Clear(); + visualPositions.Clear(); + positionCache.Clear(); + + // Re-create the visual cue list from scratch + int visPos = 0; + foreach (var (cue, pos) in EnumerateVisiblePositions()) + { + visualCues.Add(cue); + visualPositions.Add(pos); + positionCache.Add(cue, (pos, visPos)); + if (cue is GroupCueViewModel subgroup) + subgroup.OnCollapse += OnVisualGroupCollapsed; + + visPos++; + } + + OnCollectionChanged(); + } + + // These are all extensions of Insert and Delete + /*public void Duplicate() { } + + public void Move() { } + + public void Create() { }*/ + + + protected internal override void Clear() + { + base.Clear(); + ResetVisualCache(); + } + + public bool Contains(CueViewModel item) => Find(item, out _); + + public override IEnumerable Enumerate(EnumerationMode mode) + { + return mode switch + { + EnumerationMode.Root => rootCueList, + EnumerationMode.Visible => visualCues, + EnumerationMode.All => EnumerateAll(), + _ => EnumerateAll() + }; + } + + public override void SyncFromModel(Func convertCue) + { + base.SyncFromModel(convertCue); + ResetVisualCache(); + } + + public override async Task SyncFromModelAsync(ConvertCueDelegate convertCue) + { + await base.SyncFromModelAsync(convertCue); + ResetVisualCache(); + } + + public readonly struct CuePositionComparer : IComparer + { + public readonly int Compare(CuePosition x, CuePosition y) + { + if (x.group == null && y.group != null) + return 1; + if (y.group == null && x.group != null) + return -1; + + return x.index.CompareTo(y.index); + } + } + + public struct VisualCueList : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged + { + private readonly CueList list; + + public readonly int Count => list.Count; + + public readonly CueViewModel this[int index] => list.GetVisualCue(index); + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged; + + internal VisualCueList(CueList list) + { + this.list = list; + list.CollectionChanged += List_CollectionChanged; + } + + private readonly void List_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + CollectionChanged?.Invoke(sender, e); + } + + public readonly IEnumerator GetEnumerator() => list.GetEnumerator(AbstractCueList.EnumerationMode.Visible); + readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} + diff --git a/QPlayer/ViewModels/CueViewModel.cs b/QPlayer/ViewModels/CueViewModel.cs index 181ec17..a3ffbd6 100644 --- a/QPlayer/ViewModels/CueViewModel.cs +++ b/QPlayer/ViewModels/CueViewModel.cs @@ -51,22 +51,12 @@ private decimal QID_Template get => qid; set { - mainViewModel?.NotifyQIDChanged(qid, value, this); + mainViewModel.NotifyQIDChanged(qid, value, this); qid = value; } } - [Reactive, ModelBindsTo(nameof(Cue.parent))] private decimal? parentId; - public CueViewModel? Parent - { - get - { - if (parentId == null) - return null; - if (mainViewModel?.FindCue(parentId.Value, out var parent) ?? false) - return parent; - return null; - } - } + public decimal? ParentId => parent?.qid; + [Reactive, ChangesProp(nameof(ParentId)), ModelCustomBinding(nameof(VM2M_Parent), nameof(M2VM_Parent))] private CueViewModel? parent; [Reactive, ModelCustomBinding(nameof(VM2M_Colour), nameof(M2VM_Colour)), ChangesProp(nameof(ColourBrush)), SkipEqualityCheck] private ColorState colour; [Reactive] private string name = string.Empty; @@ -79,9 +69,9 @@ public CueViewModel? Parent [Reactive, ChangesProp(nameof(UseLoopCount))] private LoopMode loopMode; [Reactive] public int loopCount; - [Reactive, Readonly, ModelSkip] protected MainViewModel? mainViewModel; - public bool IsSelected => mainViewModel?.SelectedCue == this; - public bool IsMultiSelected => mainViewModel?.MultiSelection?.Contains(this) ?? false; + [Reactive, Readonly, ModelSkip] protected readonly MainViewModel mainViewModel; + public bool IsSelected => mainViewModel.SelectedCue == this; + public bool IsMultiSelected => mainViewModel.MultiSelection.Contains(this); [Reactive, ModelSkip, NoUndo] private CueState state; [Reactive, CustomAccessibility("public virtual"), SkipEqualityCheck, ModelSkip, NoUndo] private TimeSpan playbackTime; @@ -107,7 +97,7 @@ public SolidColorBrush ColourBrush [Reactive, Readonly, ModelSkip] private static ObservableCollection? fadeTypeVals; [Reactive, Readonly, ModelSkip] private static ObservableCollection? triggerModeVals; - public bool IsRemoteControlling => (mainViewModel?.ProjectSettings?.EnableRemoteControl ?? false) + public bool IsRemoteControlling => mainViewModel.ProjectSettings.EnableRemoteControl && !string.IsNullOrEmpty(RemoteNode) && RemoteNode != mainViewModel.ProjectSettings.NodeName; /// @@ -119,7 +109,6 @@ public virtual TimeSpan RemoteDuration { set { } } public event EventHandler? OnCompleted; protected SynchronizationContext? synchronizationContext; - protected CueViewModel? parent; protected DispatcherDelay goDelay; private readonly SolidColorBrush colourBrush; private CueViewModel? waitCue; @@ -199,8 +188,8 @@ public virtual void DelayedGo(CueViewModel? waitForCue = null) State = CueState.Delay; goDelay.Start(Delay); - if (!mainViewModel?.ActiveCues?.Contains(this) ?? false) - mainViewModel?.ActiveCues.Add(this); + if (!mainViewModel.ActiveCues.Contains(this)) + mainViewModel.ActiveCues.Add(this); } private void WaitCueOnCompleteHandler(object? sender, EventArgs args) @@ -220,7 +209,7 @@ private void WaitCueOnCompleteHandler(object? sender, EventArgs args) public virtual void Go() { if (IsRemoteControlling) - mainViewModel?.OSCManager.SendRemoteGo(RemoteNode, QID); + mainViewModel.OSCManager.SendRemoteGo(RemoteNode, QID); if (Duration == TimeSpan.Zero) { @@ -228,8 +217,8 @@ public virtual void Go() return; } State = CueState.Playing; - if (!mainViewModel?.ActiveCues?.Contains(this) ?? false) - mainViewModel?.ActiveCues.Add(this); + if (!mainViewModel.ActiveCues.Contains(this)) + mainViewModel.ActiveCues.Add(this); } /// @@ -243,7 +232,7 @@ public virtual void Pause() State = CueState.Paused; if (IsRemoteControlling) - mainViewModel?.OSCManager.SendRemotePause(RemoteNode, qid); + mainViewModel.OSCManager.SendRemotePause(RemoteNode, qid); } /// @@ -254,7 +243,7 @@ public virtual void Stop() StopInternal(); if (IsRemoteControlling) - mainViewModel?.OSCManager.SendRemoteStop(RemoteNode, qid); + mainViewModel.OSCManager.SendRemoteStop(RemoteNode, qid); } /// @@ -293,7 +282,7 @@ internal void StopInternal() waitCue = null; goDelay.Cancel(); State = CueState.Ready; - mainViewModel?.ActiveCues.Remove(this); + mainViewModel.ActiveCues.Remove(this); OnCompleted?.Invoke(this, EventArgs.Empty); } @@ -309,7 +298,7 @@ public virtual void Preload(TimeSpan startTime) State = CueState.Paused; if (IsRemoteControlling) - mainViewModel?.OSCManager.SendRemotePreload(RemoteNode, qid, (float)startTime.TotalSeconds); + mainViewModel.OSCManager.SendRemotePreload(RemoteNode, qid, (float)startTime.TotalSeconds); } } @@ -318,7 +307,7 @@ public virtual void Preload(TimeSpan startTime) /// public void SelectExecute() { - mainViewModel?.MultiSelect(this); + mainViewModel.MultiSelect(this); } /// @@ -330,8 +319,37 @@ protected internal virtual void UpdateUIStatus() } #endregion + /// + /// Checks whether this cue has the given cue as one of it's parents. If + /// is instance, returns . + /// + /// + /// + public bool HasParent(CueViewModel? target) + { + var p = Parent; + if (target == null) + return p == null; + + while (p != null) + { + if (p == target) + return true; + p = p.Parent; + } + return false; + } + private static void VM2M_Colour(CueViewModel vm, Cue m) => m.colour = (SerializedColour)vm.Colour; private static void M2VM_Colour(CueViewModel vm, Cue m) => vm.Colour = (ColorState)m.colour; + private static void VM2M_Parent(CueViewModel vm, Cue m) => m.parent = vm.parent?.qid; + private static void M2VM_Parent(CueViewModel vm, Cue m) + { + if (m.parent.HasValue && vm.mainViewModel.FindCue(m.parent.Value, out var parentCue)) + vm.Parent = parentCue; + else + vm.Parent = null; + } public static string EnumToString(T type) where T : Enum { diff --git a/QPlayer/ViewModels/GroupCueViewModel.cs b/QPlayer/ViewModels/GroupCueViewModel.cs index 110c5de..5082492 100644 --- a/QPlayer/ViewModels/GroupCueViewModel.cs +++ b/QPlayer/ViewModels/GroupCueViewModel.cs @@ -1,7 +1,9 @@ -using QPlayer.Models; +using QPlayer.Audio; +using QPlayer.Models; using QPlayer.SourceGenerator; using QPlayer.ThemesV2; using QPlayer.Views; +using System; namespace QPlayer.ViewModels; @@ -9,9 +11,98 @@ namespace QPlayer.ViewModels; [View(typeof(CueEditor))] [DisplayName("Group Cue")] [Icon("IconGroupCue", typeof(Icons))] -public class GroupCueViewModel : CueViewModel +public partial class GroupCueViewModel : CueViewModel { + [Reactive] private readonly SubCueList cues = []; + + [Reactive] private GroupTriggerMode groupTrigger; + private bool isCollapsed; + [Reactive("IsCollapsed"), ModelBindsTo("isCollapsed")] + private bool IsCollapsed_Template + { + get => isCollapsed; + set + { + isCollapsed = value; + OnCollapse?.Invoke(this, value); + } + } + + public event GroupCollapseEventArgs? OnCollapse; + public delegate void GroupCollapseEventArgs(GroupCueViewModel sender, bool isCollapsed); + public GroupCueViewModel(MainViewModel mainViewModel) : base(mainViewModel) { } + + public bool AddToGroup(CueViewModel cue) + { + if (cue == this || cue.Parent == this) + return false; + + var ind = mainViewModel.FindCueIndex(cue); + if (ind == -1) + return false; + + cue.Parent = this; + mainViewModel.DeleteCue(ind, false); + // cues.Add(cue); + if (ind > 0) + { + var prev = mainViewModel.Cues[ind - 1]; + // if (prev == this || prev.HasParent(this)) + } + + return true; + } + + public override void DelayedGo(CueViewModel? waitForCue = null) + { + base.DelayedGo(waitForCue); + + // Ony strictly needed for shuffling + // groupCount = EnumerateContents(true).Count(); + + switch (groupTrigger) + { + case GroupTriggerMode.Next: + mainViewModel.Go(); + break; + case GroupTriggerMode.All: + foreach (var child in Cues) + if (child.Trigger == Models.TriggerMode.Go) + mainViewModel.Go(child); + break; + case GroupTriggerMode.Shuffle: + break; + } + } + + public override void Stop() + { + base.Stop(); + foreach (var child in Cues) + child.Stop(); + } + + public override void DeVamp(Action? onDevampStart, float fadeDuration = -1, FadeType? fadeType = null) + { + base.DeVamp(onDevampStart, fadeDuration, fadeType); + foreach (var child in Cues) + child.DeVamp(null, fadeDuration, fadeType); + } + + public override void FadeOutAndStop(float duration, FadeType? fadeType = null) + { + base.FadeOutAndStop(duration, fadeType); + foreach (var child in Cues) + child.FadeOutAndStop(duration, fadeType); + } + + public override void Pause() + { + base.Pause(); + foreach (var child in Cues) + child.Pause(); + } } diff --git a/QPlayer/ViewModels/MainViewModel.CueStack.cs b/QPlayer/ViewModels/MainViewModel.CueStack.cs index 1883a37..8248601 100644 --- a/QPlayer/ViewModels/MainViewModel.CueStack.cs +++ b/QPlayer/ViewModels/MainViewModel.CueStack.cs @@ -23,12 +23,10 @@ public partial class MainViewModel /// The cue which was just removed, or null if it wasn't found. public CueViewModel? DeleteCue(int index, bool recordUndo = true) { - if (index < 0 || index >= cues.Count) + var cue = cues.Delete(index); + if (cue == null) return null; - - var cue = cues[index]; - Cues.RemoveAt(index); - showFile.cues.RemoveAt(index); + if (recordUndo) UndoManager.RegisterAction($"Deleted cue '{cue.Name}' ({cue.QID})", () => { SelectedCue = null; InsertCue(index, cue, true); }, @@ -72,24 +70,29 @@ public bool DeleteCue(decimal qid) /// The cues which were just removed, or null if they weren't found. public CueViewModel[] DeleteCues(int startIndex, int count = 1, bool recordUndo = true) { - if (count == 1) + if (count == 0 || startIndex < 0 || startIndex >= cues.Count) + return []; + + if (count == 1 && cues[startIndex] is not GroupCueViewModel) { + // Simple case where only one cue is being deleted var single = DeleteCue(startIndex, recordUndo); return single == null ? [] : [single]; } - if (startIndex < 0) + + int lastInd = startIndex + count - 1; + if (lastInd >= cues.Count) return []; using TemporaryList deleted = []; for (int i = count - 1; i >= 0; i--) { int ind = i + startIndex; - if (ind >= cues.Count) + + var cue = cues.Delete(ind); + if (cue == null) break; - var cue = cues[ind]; - Cues.RemoveAt(ind); - showFile.cues.RemoveAt(ind); deleted.Add(cue); } @@ -126,10 +129,9 @@ public CueViewModel[] DeleteCues(int[] indices, bool recordUndo = true) for (int i = indices.Length - 1; i >= 0; i--) { int ind = indices[i]; - var cue = cues[ind]; - Cues.RemoveAt(ind); - showFile.cues.RemoveAt(ind); - deleted.Add(cue); + var cue = cues.Delete(ind); + if (cue != null) + deleted.Add(cue); } var deletedArr = deleted.Reverse().ToArray(); @@ -151,11 +153,12 @@ public CueViewModel[] DeleteCues(int[] indices, bool recordUndo = true) /// Whether an undo item should be recorded. public void InsertCue(int index, CueViewModel cue, bool select = false, bool registerUndo = true) { + index = Math.Clamp(index, 0, cues.Count); Cues.Insert(index, cue); showFile.cues.Insert(index, cue.BoundModel!); if (registerUndo) - UndoManager.RegisterAction($"Inserted cue '{cue.Name}' ({cue.QID})", - () => DeleteCue(index), + UndoManager.RegisterAction($"Inserted cue '{cue.Name}' ({cue.QID})", + () => DeleteCue(index), () => InsertCue(index, cue)); if (select) SelectedCueInd = index; @@ -169,7 +172,7 @@ public void InsertCue(int index, CueViewModel cue, bool select = false, bool reg /// The cue to insert in the stack. /// Whether the newly inserted cue should be selected. /// Whether an undo item should be recorded. - public void InsertCue(CueViewModel cue, bool select = false, bool registerUndo = true) + /*public void InsertCue(CueViewModel cue, bool select = false, bool registerUndo = true) { int ind; var newId = cue.QID; @@ -183,7 +186,7 @@ public void InsertCue(CueViewModel cue, bool select = false, bool registerUndo = } cue.QID = newId; InsertCue(ind, cue, select, registerUndo); - } + }*/ /// /// Inserts existing cues into the cue stack. This method should be used with care as @@ -243,7 +246,7 @@ public void DuplicateCues(IEnumerable cues, bool select = true, bo } else { - dstInd = cues.Select(x => FindCueIndex(x)).Max() + 1; + dstInd = cues.Max(x => FindCueIndex(x)) + 1; } DuplicateCues(cues, dstInd, select, registerUndo); @@ -326,7 +329,7 @@ public bool FindCue(decimal id, [NotNullWhen(true)] out CueViewModel? cue) } /// - /// Gets the index of a cue in the cue list. Executes in O(n) time. + /// Gets the index of a cue in the cue list. /// /// The cue instance to search for. /// The index of the cue in the list or -1 if it wasn't found. @@ -337,7 +340,7 @@ public int FindCueIndex(CueViewModel? cue) // If we could guarantee that the cue list was in order, we could use a binary // search, but this can't be guaranteed. If there's ever a need, we could also // maintain an index dictionary. - return cues.IndexOf(cue); + return cues.FindVisualIndex(cue); } /// @@ -431,20 +434,8 @@ public void MoveCues(IEnumerable cues, int index, bool select = tr srcIndices.Sort(); var srcArray = srcIndices.Select(x => this.cues[x]).ToArray(); var srcQIDs = srcArray.Select(x => x.QID).ToArray(); - int removedBefore = 0; - foreach (var ind in srcIndices.FastReverse()) - { - DeleteCue(ind, false); - if (ind < index) - removedBefore++; - } - int i = index - removedBefore; - foreach (var cue in srcArray) - { - cue.QID = ChooseQID(i - 1, false); - InsertCue(i, cue, false, false); - i++; - } + + MoveCuesInternal(srcArray, srcIndices, index, out int removedBefore); if (select) MultiSelect(Enumerable.Range(index - removedBefore, srcIndices.Length)); @@ -458,6 +449,33 @@ public void MoveCues(IEnumerable cues, int index, bool select = tr } } + /// + /// Moves an ordered array of cues to the new index in the cue stack. + /// This method assumes that and are already + /// sorted. + /// + /// The sorted array of cues. + /// The sorted array of cue indices. + /// The starting index to move the cues to. + /// The number of cues which were at an index smaller than . + private void MoveCuesInternal(CueViewModel[] cues, int[] indices, int index, out int removedBefore) + { + removedBefore = 0; + foreach (var ind in indices.FastReverse()) + { + DeleteCue(ind, false); + if (ind < index) + removedBefore++; + } + int i = index - removedBefore; + foreach (var cue in cues) + { + cue.QID = ChooseQID(i - 1, false); + InsertCue(i, cue, false, false); + i++; + } + } + private void MoveCuesBack(CueViewModel[] cues, int[] indices, decimal[] qids) { using var _ = UndoManager.ScopedSuppress(); @@ -583,8 +601,9 @@ public bool MoveCue(int srcIndex, int dstIndex, bool select = true, decimal? new /// The index in the cue stac to insert the cue /// Whether the newly created cue should be selected. /// Optionally, a cue to copy properties from. + /// Whether an undo item should be recorded. /// The view model instance of the newly created cue. - public CueViewModel? CreateCue(string? type, int index, bool select = true, CueViewModel? src = null) + public CueViewModel? CreateCue(string? type, int index, bool select = true, CueViewModel? src = null, bool recordUndo = true) { index = Math.Clamp(index, 0, cues.Count); @@ -596,8 +615,13 @@ public bool MoveCue(int srcIndex, int dstIndex, bool select = true, decimal? new InsertCue(index, cue, select, false); - string action = src == null ? $"Created {cue.TypeDisplayName}" : $"Duplicated '{cue.Name}'"; - UndoManager.RegisterAction($"{action} ({qid})", () => DeleteCue(index, false), () => InsertCue(index, cue, select, false)); + if (recordUndo) + { + string action = src == null ? $"Created {cue.TypeDisplayName}" : $"Duplicated '{cue.Name}'"; + UndoManager.RegisterAction($"{action} ({qid})", + () => DeleteCue(index, false), + () => InsertCue(index, cue, select, false)); + } return cue; } @@ -686,6 +710,81 @@ public bool RenumberCues(IEnumerable cueInds, decimal startID = -1, decimal return true; } + /// + /// Creates a new group cue containing the specified cues. + /// + /// The cues to put in the group, the order within the cue stack of these cues is maintained. + /// Whether an undo item should be recorded. + public void GroupCues(IEnumerable cues, bool recordUndo = true) + { + using var srcIndices = cues.Select(x => FindCueIndex(x)).ToTempList(); + if (srcIndices.Count == 0) + return; + + srcIndices.Sort(); + + GroupCues([.. srcIndices.Select(x => this.cues[x])], srcIndices[0], recordUndo); + } + + /// + /// Creates a new group cue containing the specified cues. + /// + /// The ordered indices of the cues to add to the group. + /// Whether an undo item should be recorded. + public void GroupCues(CueViewModel[] cues, int dstIndex, bool recordUndo = true, int[]? cueIndices = null) + { + if (cues.Length == 0) + return; + + cueIndices ??= [.. cues.Select(x => FindCueIndex(x))]; + var firstInd = cueIndices[0]; + if (firstInd == -1) + return; + // if they all share the same parent, make a subgroup + var parent = cues[0].Parent; + foreach (var cue in cues) + { + + } + + UndoManager.SuppressRecording(); + + var group = (GroupCueViewModel)CreateCue(nameof(GroupCue), firstInd, false)!; + group.Parent = parent; + + // Bulk move all the cues to the right place before adding each to the group to avoid more work later. + MoveCuesInternal(cues, cueIndices, dstIndex, out var removedBefore); + foreach (var cue in cues) + group.AddToGroup(cue); + UndoManager.UnSuppressRecording(); + + if (recordUndo) + UndoManager.RegisterAction($"Grouped {cues.Length} cues", + () => UngroupCues(group, false), + () => GroupCues(cues, dstIndex, false, cueIndices)); + } + + /// + /// + /// + /// + /// Whether an undo item should be recorded. + public void UngroupCues(IEnumerable cues, bool recordUndo = true) + { + //foreach (var cue in cues) + // cue.Parent + } + + /// + /// + /// + /// + /// Whether an undo item should be recorded. + public void UngroupCues(GroupCueViewModel group, bool recordUndo = true) + { + + } + /// /// Generates a QID at the given insertion point in the cue stack, renumbering cues if needed. /// diff --git a/QPlayer/ViewModels/MainViewModel.FileManagement.cs b/QPlayer/ViewModels/MainViewModel.FileManagement.cs index 4c6ee93..77d7909 100644 --- a/QPlayer/ViewModels/MainViewModel.FileManagement.cs +++ b/QPlayer/ViewModels/MainViewModel.FileManagement.cs @@ -183,24 +183,29 @@ private async Task LoadShowfileModel(ShowFile show, bool sync = false) ColumnWidths[i].Value = showFile.columnWidths[i]; Cues.Clear(); - for (int i = 0; i < showFile.cues.Count; i++) + Cues.Bind(show.cues); + int totalCueCount = CountModelCues(show.cues); + int j = 0; + await Cues.SyncFromModelAsync(async cue => { - ProgressBoxViewModel.Progress = (i + 1) / (float)showFile.cues.Count; - ProgressBoxViewModel.Message = $"Loading cues... ({i + 1}/{showFile.cues.Count})"; + ProgressBoxViewModel.Progress = (j + 1) / (float)showFile.cues.Count; + ProgressBoxViewModel.Message = $"Loading cues... ({j + 1}/{showFile.cues.Count})"; if (!sync) - await Dispatcher.Yield(); - Cue c = showFile.cues[i]; + await Dispatcher.Yield(DispatcherPriority.Input); + + j++; try { - var vm = CueFactory.CreateViewModelForCue(c, this) - ?? throw new Exception($"Couldn't create view model for cue of type {c.GetType().Name}, qid: {c.qid}!"); - Cues.Add(vm); + var vm = CueFactory.CreateViewModelForCue(cue, this) + ?? throw new Exception($"Couldn't create view model for cue of type {cue.GetType().Name}, qid: {cue.qid}!"); + return vm; } catch (Exception ex) { Log($"Error occurred while trying to create cue from save file! {ex.Message}\n{ex}", LogLevel.Error); } - } + return null; + }); OnPropertyChanged(nameof(SelectedCue)); oscManager.ConnectOSC(); @@ -208,6 +213,19 @@ private async Task LoadShowfileModel(ShowFile show, bool sync = false) OpenAudioDevice(); } + /// + /// Counts the total number of cues in a showfile model, recursively traversing group cues to determine the total count. + /// + /// + /// + private int CountModelCues(List cues) + { + int count = cues.Count; + foreach (var group in cues.OfType()) + count += CountModelCues(group.cues); + return count; + } + /// /// This effectively makes sure that the data in the view model is copied to the model, just in case a change was missed. /// diff --git a/QPlayer/ViewModels/MainViewModel.Transport.cs b/QPlayer/ViewModels/MainViewModel.Transport.cs index e37c89a..80e9c27 100644 --- a/QPlayer/ViewModels/MainViewModel.Transport.cs +++ b/QPlayer/ViewModels/MainViewModel.Transport.cs @@ -22,6 +22,7 @@ public void Go(CueViewModel? cue) if (cue == null) return; + SelectedCue = cue; CueViewModel? waitCue = null; int i = SelectedCueInd; diff --git a/QPlayer/ViewModels/MainViewModel.cs b/QPlayer/ViewModels/MainViewModel.cs index ff366bf..7fff89d 100644 --- a/QPlayer/ViewModels/MainViewModel.cs +++ b/QPlayer/ViewModels/MainViewModel.cs @@ -61,7 +61,7 @@ private CueViewModel? SelectedCue_Template } [Reactive] private readonly ObservableSelectionSet multiSelection; [Reactive] private SelectionMode selectionMode = SelectionMode.Normal; - [Reactive] private readonly ObservableCollection cues; + [Reactive] private readonly CueList cues; [Reactive] private readonly ObservableCollection activeCues; [Reactive] private readonly ObservableCollection> columnWidths; [Reactive] private readonly ObservableCollection draggingCues; diff --git a/QPlayer/ViewModels/SoundCueViewModel.cs b/QPlayer/ViewModels/SoundCueViewModel.cs index 0fbb8b6..c3c3da7 100644 --- a/QPlayer/ViewModels/SoundCueViewModel.cs +++ b/QPlayer/ViewModels/SoundCueViewModel.cs @@ -168,7 +168,7 @@ public override void Go() PlaybackTime = TimeSpan.Zero; } - if (!IsAudioFileValid || fadeInOutProvider == null || mainViewModel == null || volumeFadeProvider == null) + if (!IsAudioFileValid || fadeInOutProvider == null || volumeFadeProvider == null) return; // Cancel any active fades @@ -202,7 +202,7 @@ public override void Go() public override void Pause() { base.Pause(); - if (IsRemoteControlling || fadeInOutProvider == null || volumeFadeProvider == null || mainViewModel == null) + if (IsRemoteControlling || fadeInOutProvider == null || volumeFadeProvider == null) return; volumeFadeProvider.BeginFade(0, 5, onComplete: _ => mainViewModel.AudioPlaybackManager.StopSound(fadeInOutProvider), useSyncContext: false); OnPropertyChanged(nameof(PlaybackTime)); @@ -214,7 +214,7 @@ public override void Stop() if (IsRemoteControlling) return; if (shouldSendRemoteStatus) - mainViewModel?.OSCManager?.SendRemoteStatus(RemoteNode, qid, State); + mainViewModel.OSCManager.SendRemoteStatus(RemoteNode, qid, State); StopAudio(); PlaybackTime = TimeSpan.Zero; loopingAudioStream?.EndTime = StartTime + PlaybackDuration; @@ -227,7 +227,7 @@ public override void Stop() /// The type of fade to use public override void FadeOutAndStop(float duration, FadeType? fadeType = null) { - if (volumeFadeProvider == null || mainViewModel == null) + if (volumeFadeProvider == null) return; switch (State) { @@ -259,7 +259,7 @@ public override void FadeOutAndStop(float duration, FadeType? fadeType = null) /// The type of fade to use public void Fade(float volume, float duration, FadeType? fadeType = null) { - if (volumeFadeProvider == null || mainViewModel == null) + if (volumeFadeProvider == null) return; switch (State) { @@ -337,7 +337,7 @@ protected internal override void UpdateUIStatus() OnPropertyChanged(nameof(PlaybackTime)); if (shouldSendRemoteStatus) - mainViewModel?.OSCManager?.SendRemoteStatus(thisNodeName ?? string.Empty, qid, State, + mainViewModel.OSCManager.SendRemoteStatus(thisNodeName ?? string.Empty, qid, State, State != CueState.Ready ? (float)PlaybackTime.TotalSeconds : null); } @@ -352,7 +352,7 @@ private void FadeOut_Completed(bool completed) /// private void StopAudio() { - if (!IsAudioFileValid || fadeInOutProvider == null || mainViewModel == null) + if (!IsAudioFileValid || fadeInOutProvider == null) return; mainViewModel.AudioPlaybackManager.StopSound(fadeInOutProvider); audioFile?.ReleaseBuffers(); @@ -365,12 +365,9 @@ private void UnloadAudioFile() audioFile = null; OnPropertyChanged(nameof(Duration)); } - + private void LoadAudioFile() { - if (mainViewModel == null) - return; - UnloadAudioFile(); var path = mainViewModel.ResolvePath(Path); @@ -420,5 +417,5 @@ private void LoadAudioFile() } } - private static void VM2M_Path(SoundCueViewModel vm, SoundCue m) => m.path = vm.MainViewModel?.ResolvePath(vm.MainViewModel.ResolvePath(vm.Path), false) ?? vm.Path; + private static void VM2M_Path(SoundCueViewModel vm, SoundCue m) => m.path = vm.MainViewModel.ResolvePath(vm.MainViewModel.ResolvePath(vm.Path), false) ?? vm.Path; } diff --git a/QPlayer/ViewModels/StopCueViewModel.cs b/QPlayer/ViewModels/StopCueViewModel.cs index b334f7f..4f98694 100644 --- a/QPlayer/ViewModels/StopCueViewModel.cs +++ b/QPlayer/ViewModels/StopCueViewModel.cs @@ -49,7 +49,7 @@ public override void Go() // Stop cues don't support preloading PlaybackTime = TimeSpan.Zero; startTime = DateTime.UtcNow; - if (mainViewModel != null && mainViewModel.FindCue(StopTarget, out var cue)) + if (mainViewModel.FindCue(StopTarget, out var cue)) { if (stopMode == StopMode.LoopEnd) { diff --git a/QPlayer/ViewModels/VolumeCueViewModel.cs b/QPlayer/ViewModels/VolumeCueViewModel.cs index 7557acb..43787ec 100644 --- a/QPlayer/ViewModels/VolumeCueViewModel.cs +++ b/QPlayer/ViewModels/VolumeCueViewModel.cs @@ -48,7 +48,7 @@ public override void Go() // Volume cues don't support preloading PlaybackTime = TimeSpan.Zero; startTime = DateTime.Now; - if (mainViewModel != null && mainViewModel.FindCue(Target, out var cue)) + if (mainViewModel.FindCue(Target, out var cue)) { if (cue is SoundCueViewModel soundCue) soundCue.Fade(MathF.Pow(10, Volume / 20f), FadeTime, FadeType); diff --git a/QPlayer/Views/CueDataControl.xaml b/QPlayer/Views/CueDataControl.xaml index ad20e30..f10b744 100644 --- a/QPlayer/Views/CueDataControl.xaml +++ b/QPlayer/Views/CueDataControl.xaml @@ -8,9 +8,10 @@ xmlns:util="clr-namespace:QPlayer.Utilities" d:DataContext="{d:DesignInstance Type=vm:DummyCueViewModel}" mc:Ignorable="d" - d:DesignHeight="50" d:DesignWidth="800" Loaded="UserControl_Loaded"> + d:DesignHeight="50" d:DesignWidth="800" Loaded="UserControl_Loaded" DataContextChanged="UserControl_DataContextChanged"> + - + + -