diff --git a/.gitignore b/.gitignore
index a57e89a..a84c072 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,7 @@ SigningCertificate_Encoded.txt
*.zip
/QPlayerOld
/api
+/TestProj
+/TestResults
+/QPlayer.Tests/CueListTests2.cs
+/QPlayer.Tests/CueListTests3.cs
diff --git a/QPlayer.MagicQCTRLPlugin/MagicQCTRLPlugin.cs b/QPlayer.MagicQCTRLPlugin/MagicQCTRLPlugin.cs
index 9012865..d3e79d7 100644
--- a/QPlayer.MagicQCTRLPlugin/MagicQCTRLPlugin.cs
+++ b/QPlayer.MagicQCTRLPlugin/MagicQCTRLPlugin.cs
@@ -61,7 +61,10 @@ private void MagicQCTRLTask()
driver?.OnMessageReceived -= Driver_OnMessageReceived;
MainViewModel.Log($"MagicQCTRL disconnected due to an error: {ex.Message}", MainViewModel.LogLevel.Warning);
}
- driver?.Dispose();
+ finally
+ {
+ driver?.Dispose();
+ }
}
}
diff --git a/QPlayer.MagicQCTRLPlugin/QPlayer.MagicQCTRLPlugin.csproj b/QPlayer.MagicQCTRLPlugin/QPlayer.MagicQCTRLPlugin.csproj
index 15442ab..3269823 100644
--- a/QPlayer.MagicQCTRLPlugin/QPlayer.MagicQCTRLPlugin.csproj
+++ b/QPlayer.MagicQCTRLPlugin/QPlayer.MagicQCTRLPlugin.csproj
@@ -7,7 +7,7 @@
true
true
- 0.1.1
+ 0.1.2
Thomas Mathieson
Thomas Mathieson
©️ Thomas Mathieson 2026
diff --git a/QPlayer.MagicQCTRLPlugin/USBDriver.cs b/QPlayer.MagicQCTRLPlugin/USBDriver.cs
index 51329c5..db74c16 100644
--- a/QPlayer.MagicQCTRLPlugin/USBDriver.cs
+++ b/QPlayer.MagicQCTRLPlugin/USBDriver.cs
@@ -38,10 +38,12 @@ internal class USBDriver : IDisposable
public void USBConnectAsync()
{
- DeviceList.Local.Changed += (o, e) =>
- {
- USBConnect();
- };
+ DeviceList.Local.Changed += OnDeviceListChangedHandler;
+ }
+
+ private void OnDeviceListChangedHandler(object? sender, EventArgs e)
+ {
+ USBConnect();
}
///
@@ -203,6 +205,11 @@ public void Dispose()
usbRXTask?.Dispose();
}
catch { }
+ try
+ {
+ DeviceList.Local.Changed -= OnDeviceListChangedHandler;
+ }
+ catch { }
usbDevice = null;
OnConnectionStatusChanged?.Invoke(false);
isDisposing = false;
diff --git a/QPlayer.OSCCuePlugin/OSCCueViewModel.cs b/QPlayer.OSCCuePlugin/OSCCueViewModel.cs
index df45f6c..9ffddcf 100644
--- a/QPlayer.OSCCuePlugin/OSCCueViewModel.cs
+++ b/QPlayer.OSCCuePlugin/OSCCueViewModel.cs
@@ -41,8 +41,21 @@ private bool OSCMessageValid_Template
}
}*/
+ [SkipView]
+ public override string NamePreview => string.IsNullOrEmpty(Name) ? $"OSC {command}" : Name;
+
public OSCCueViewModel(MainViewModel mainViewModel) : base(mainViewModel)
- { }
+ {
+ PropertyChanged += (o, e) =>
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(Command):
+ OnPropertyChanged(nameof(NamePreview));
+ break;
+ }
+ };
+ }
public override void Go()
{
diff --git a/QPlayer.OSCCuePlugin/QPlayer.OSCCuePlugin.csproj b/QPlayer.OSCCuePlugin/QPlayer.OSCCuePlugin.csproj
index b3677d9..8085d95 100644
--- a/QPlayer.OSCCuePlugin/QPlayer.OSCCuePlugin.csproj
+++ b/QPlayer.OSCCuePlugin/QPlayer.OSCCuePlugin.csproj
@@ -7,7 +7,7 @@
true
QPlayer OSC Cue Plugin
- 0.1.0
+ 0.1.1
Thomas Mathieson
Thomas Mathieson
©️ Thomas Mathieson 2026
diff --git a/QPlayer.PyPlayPlugin/FramingCueView.xaml b/QPlayer.PyPlayPlugin/FramingCueView.xaml
new file mode 100644
index 0000000..89735ef
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/FramingCueView.xaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/FramingCueView.xaml.cs b/QPlayer.PyPlayPlugin/FramingCueView.xaml.cs
new file mode 100644
index 0000000..a6c4f78
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/FramingCueView.xaml.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Interaction logic for FramingCueView.xaml
+///
+public partial class FramingCueView : UserControl
+{
+ public FramingCueView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/FramingCueViewModel.cs b/QPlayer.PyPlayPlugin/FramingCueViewModel.cs
new file mode 100644
index 0000000..12e558b
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/FramingCueViewModel.cs
@@ -0,0 +1,31 @@
+using QPlayer.Audio;
+using QPlayer.SourceGenerator;
+using QPlayer.Utilities;
+using QPlayer.ViewModels;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace QPlayer.PyPlayPlugin;
+
+[Model(typeof(VideoFramingCue))]
+//[GenerateView]
+[View(typeof(FramingCueView))]
+[DisplayName("Video Framing Cue")]
+[Icon("IconPyFramingCue", typeof(Icons))]
+public partial class FramingCueViewModel(MainViewModel mainViewModel) : CueViewModel(mainViewModel)
+{
+ [Reactive] private UndoableObservableCollection corners = [..Enumerable.Repeat(default, 4)];
+ [Reactive] private UndoableObservableCollection framing = [.. Enumerable.Range(0, 4).Select(x => new FramingShutterViewModel())];
+ [Reactive] private float fadeTime = 0;
+ [Reactive] private FadeType fadeType = FadeType.SCurve;
+}
+
+public partial class FramingShutterViewModel : BindableViewModel
+{
+ [Reactive] private float rotation;
+ [Reactive] private float maskStart;
+ [Reactive] private float softness;
+}
diff --git a/QPlayer.PyPlayPlugin/FramingShutterControl.xaml b/QPlayer.PyPlayPlugin/FramingShutterControl.xaml
new file mode 100644
index 0000000..cd0bac0
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/FramingShutterControl.xaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ F0 M50,-20 h-100 v20 A50,50 0 0 0 -35.3553,35.3553 A150,80 0 0 1 -15,75 v20 a5,5 0 0 0 5,5 h20 a5,5 0 0 0 5,-5 v-20 A150,80 0 0 1 35.3553,35.3553 A50,50 0 0 0 50,0 v-20z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/FramingShutterControl.xaml.cs b/QPlayer.PyPlayPlugin/FramingShutterControl.xaml.cs
new file mode 100644
index 0000000..0d98b64
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/FramingShutterControl.xaml.cs
@@ -0,0 +1,97 @@
+using QPlayer.Views;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Interaction logic for FramingShutterControl.xaml
+///
+public partial class FramingShutterControl : UserControl
+{
+ bool isDragging = false;
+ Point lastPos;
+
+ public FramingShutterControl()
+ {
+ InitializeComponent();
+ }
+
+ private void Rectangle_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ isDragging = true;
+ LibraryImports.ShowCursor(false);
+ lastPos = e.GetPosition(ContainerGrid);
+ (sender as FrameworkElement)?.CaptureMouse();
+ //Debug.WriteLine($"down left={e.LeftButton} right={e.RightButton} drag={isDragging}");
+ }
+
+ private void Rectangle_MouseUp(object sender, MouseButtonEventArgs e)
+ {
+ //Debug.WriteLine($"up left={e.LeftButton} right={e.RightButton} drag={isDragging}");
+ }
+
+ private void Rectangle_MouseMove(object sender, MouseEventArgs e)
+ {
+ //Debug.WriteLine($"move left={e.LeftButton} right={e.RightButton} drag={isDragging}");
+ if (!isDragging || DataContext is not FramingShutterViewModel shutter)
+ return;
+
+ if (e.LeftButton == MouseButtonState.Released && e.RightButton == MouseButtonState.Released)
+ {
+ (sender as FrameworkElement)?.ReleaseMouseCapture();
+ isDragging = false;
+ ContainerGrid.Opacity = 0.7;
+ // Sometimes the MouseUp call gets eaten, make sure to unhide the mouse here...
+ int count;
+ do
+ {
+ count = LibraryImports.ShowCursor(true);
+ } while (count < 0);
+ while (count > 0)
+ {
+ count = LibraryImports.ShowCursor(false);
+ }
+ return;
+ }
+
+ var pos = e.GetPosition(ContainerGrid);
+ var off = pos - lastPos;
+ lastPos = pos;
+
+ var x = off.X / ContainerGrid.ActualWidth;
+ var y = off.Y / ContainerGrid.ActualHeight;
+
+ shutter.Rotation = Math.Clamp(shutter.Rotation - (float)(x * 150), -120, 120);
+ if (e.RightButton == MouseButtonState.Pressed)
+ shutter.Softness = Math.Clamp(shutter.Softness + (float)(y*2), 0, 1);
+ else
+ shutter.MaskStart = Math.Clamp(shutter.MaskStart - (float)(y*2), 0, 1);
+ }
+
+ private void Rectangle_MouseEnter(object sender, MouseEventArgs e)
+ {
+ ContainerGrid.Opacity = 1.0;
+ }
+
+ private void Rectangle_MouseLeave(object sender, MouseEventArgs e)
+ {
+ // Debug.WriteLine($"leave left={e.LeftButton} right={e.RightButton} drag={isDragging}");
+ if (isDragging)
+ return;
+
+ ContainerGrid.Opacity = 0.7;
+ //LibraryImports.ShowCursor(true);
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/Icons.xaml b/QPlayer.PyPlayPlugin/Icons.xaml
new file mode 100644
index 0000000..837c3f7
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/Icons.xaml
@@ -0,0 +1,24 @@
+
+
+ F1 M512,512z M0,0z M0,96C0,60.7,28.7,32,64,32L448,32C483.3,32,512,60.7,512,96L512,416C512,451.3,483.3,480,448,480L64,480C28.7,480,0,451.3,0,416L0,96z M48,368L48,400C48,408.8,55.2,416,64,416L96,416C104.8,416,112,408.8,112,400L112,368C112,359.2,104.8,352,96,352L64,352C55.2,352,48,359.2,48,368z M416,352C407.2,352,400,359.2,400,368L400,400C400,408.8,407.2,416,416,416L448,416C456.8,416,464,408.8,464,400L464,368C464,359.2,456.8,352,448,352L416,352z M48,240L48,272C48,280.8,55.2,288,64,288L96,288C104.8,288,112,280.8,112,272L112,240C112,231.2,104.8,224,96,224L64,224C55.2,224,48,231.2,48,240z M416,224C407.2,224,400,231.2,400,240L400,272C400,280.8,407.2,288,416,288L448,288C456.8,288,464,280.8,464,272L464,240C464,231.2,456.8,224,448,224L416,224z M48,112L48,144C48,152.8,55.2,160,64,160L96,160C104.8,160,112,152.8,112,144L112,112C112,103.2,104.8,96,96,96L64,96C55.2,96,48,103.2,48,112z M416,96C407.2,96,400,103.2,400,112L400,144C400,152.8,407.2,160,416,160L448,160C456.8,160,464,152.8,464,144L464,112C464,103.2,456.8,96,448,96L416,96z M160,128L160,192C160,209.7,174.3,224,192,224L320,224C337.7,224,352,209.7,352,192L352,128C352,110.3,337.7,96,320,96L192,96C174.3,96,160,110.3,160,128z M192,288C174.3,288,160,302.3,160,320L160,384C160,401.7,174.3,416,192,416L320,416C337.7,416,352,401.7,352,384L352,320C352,302.3,337.7,288,320,288L192,288z
+
+
+
+
+ F1 M512,512z M0,0z M0,416C0,433.7,14.3,448,32,448L86.7,448C99,476.3 127.2,496 160,496 192.8,496 221,476.3 233.3,448L480,448C497.7,448 512,433.7 512,416 512,398.3 497.7,384 480,384L233.3,384C221,355.7 192.8,336 160,336 127.2,336 99,355.7 86.7,384L32,384C14.3,384,0,398.3,0,416z M128,416A32,32,0,1,1,192,416A32,32,0,1,1,128,416z M320,256A32,32,0,1,1,384,256A32,32,0,1,1,320,256z M352,176C319.2,176,291,195.7,278.7,224L32,224C14.3,224 0,238.3 0,256 0,273.7 14.3,288 32,288L278.7,288C291,316.3 319.2,336 352,336 384.8,336 413,316.3 425.3,288L480,288C497.7,288 512,273.7 512,256 512,238.3 497.7,224 480,224L425.3,224C413,195.7,384.8,176,352,176z M192,128A32,32,0,1,1,192,64A32,32,0,1,1,192,128z M265.3,64C253,35.7 224.8,16 192,16 159.2,16 131,35.7 118.7,64L32,64C14.3,64 0,78.3 0,96 0,113.7 14.3,128 32,128L118.7,128C131,156.3 159.2,176 192,176 224.8,176 253,156.3 265.3,128L480,128C497.7,128 512,113.7 512,96 512,78.3 497.7,64 480,64L265.3,64z
+
+
+
+
+ F1 M512,512z M0,0z M128,32C128,14.3 113.7,0 96,0 78.3,0 64,14.3 64,32L64,64 32,64C14.3,64 0,78.3 0,96 0,113.7 14.3,128 32,128L64,128 64,384C64,419.3,92.7,448,128,448L352,448 352,384 128,384 128,32z M384,480C384,497.7 398.3,512 416,512 433.7,512 448,497.7 448,480L448,448 480,448C497.7,448 512,433.7 512,416 512,398.3 497.7,384 480,384L448,384 448,128C448,92.7,419.3,64,384,64L160,64 160,128 384,128 384,480z
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/QPlayer.PyPlayPlugin/Icons.xaml.cs b/QPlayer.PyPlayPlugin/Icons.xaml.cs
new file mode 100644
index 0000000..079b0f9
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/Icons.xaml.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+
+namespace QPlayer.PyPlayPlugin;
+
+public partial class Icons : ResourceDictionary
+{
+ public Icons()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/PyPlayPlugin.cs b/QPlayer.PyPlayPlugin/PyPlayPlugin.cs
new file mode 100644
index 0000000..0e1c9e1
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/PyPlayPlugin.cs
@@ -0,0 +1,19 @@
+using QPlayer.Models;
+using QPlayer.ViewModels;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+
+namespace QPlayer.PyPlayPlugin;
+
+[PluginName("PyPlay Plugin")]
+[PluginAuthor("Thomas Mathieson")]
+[PluginDescription("This plugin adds support for PyPlay video and shader cues. PyPlay is a flexible cross-platform video playback engine. https://github.com/dmathies/pyPlay")]
+public class PyPlayPlugin : QPlayerPlugin
+{
+ public override void OnLoad(MainViewModel mainViewModel)
+ {
+ MainViewModel.Log("Loaded PyPlay plugin!");
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/PyVideoCueModel.cs b/QPlayer.PyPlayPlugin/PyVideoCueModel.cs
new file mode 100644
index 0000000..5412efa
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/PyVideoCueModel.cs
@@ -0,0 +1,113 @@
+using QPlayer.Audio;
+using QPlayer.Models;
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Model for the Video Cue
+///
+public record PyVideoCue : Cue
+{
+ public string path = string.Empty;
+ public string shader = string.Empty;
+ public int zIndex;
+ public string? alphaPath;
+ public AlphaMode alphaMode = AlphaMode.Video;
+ public TimeSpan startTime;
+ public TimeSpan duration;
+ public bool stompsOthers;
+ public float dimmer = 1;
+ public float volume = 1;
+ public float fadeIn = 1;
+ public float fadeOut = 1;
+ public FadeType fadeType = FadeType.SCurve;
+ public float brightness = 1;
+ public float contrast = 1;
+ public float gamma = 1;
+ public float scale = 1;
+ public float rotation = 0;
+ public Vector2 offset = Vector2.Zero;
+ public List shaderParameters = [];
+ // DMX address as a single integer (1..508). 0 means unset.
+ public int dmxAddress = 0;
+
+ public PyVideoCue() : base() { }
+}
+
+///
+/// Model for the Video Framing Cue
+///
+[Serializable]
+public record VideoFramingCue : Cue
+{
+ public List corners = [.. Enumerable.Repeat(default, 4)];
+ public List framing = [.. Enumerable.Range(0, 4).Select(x => new FramingShutter())];
+ public float fadeTime = 0;
+ public FadeType fadeType = FadeType.SCurve;
+
+ public VideoFramingCue() : base() { }
+}
+
+///
+/// Model for the Shader Parameters Cue
+///
+[Serializable]
+public record ShaderParamsCue : Cue
+{
+ ///
+ /// Can be numeric or the string "post" for post-processing target.
+ ///
+ public string targetQid = string.Empty;
+ public List shaderParameters = [];
+ public float fadeTime = 0;
+ public FadeType fadeType = FadeType.SCurve;
+ public bool postProcessing = false;
+
+ public ShaderParamsCue() : base() { }
+}
+
+///
+/// Enum for alpha blending modes
+///
+public enum AlphaMode
+{
+ Opaque,
+ Video,
+ Alpha,
+ GradientWipe
+}
+
+///
+/// Struct for framing shutter configuration
+///
+public record FramingShutter
+{
+ public float rotation;
+ public float maskStart;
+ public float softness;
+}
+
+///
+/// Struct for shader parameters
+///
+[Serializable]
+public record class ShaderParameter
+{
+ public string name = string.Empty;
+ public float value;
+
+ public ShaderParameter() { }
+
+ public ShaderParameter(string name, float value)
+ {
+ this.name = name;
+ this.value = value;
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/PyVideoCueView.xaml b/QPlayer.PyPlayPlugin/PyVideoCueView.xaml
new file mode 100644
index 0000000..b8fc28c
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/PyVideoCueView.xaml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/PyVideoCueView.xaml.cs b/QPlayer.PyPlayPlugin/PyVideoCueView.xaml.cs
new file mode 100644
index 0000000..e6bc87b
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/PyVideoCueView.xaml.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Interaction logic for PyVideoCueView.xaml
+///
+public partial class PyVideoCueView
+{
+ public PyVideoCueView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/PyVideoCueViewModel.cs b/QPlayer.PyPlayPlugin/PyVideoCueViewModel.cs
new file mode 100644
index 0000000..bc7ca18
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/PyVideoCueViewModel.cs
@@ -0,0 +1,162 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+using QPlayer.Audio;
+using QPlayer.SourceGenerator;
+using QPlayer.Utilities;
+using QPlayer.ViewModels;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace QPlayer.PyPlayPlugin;
+
+[Model(typeof(PyVideoCue))]
+//[GenerateView]
+[View(typeof(PyVideoCueView))]
+[DisplayName("Video Cue")]
+[Icon("IconPyVideoCue", typeof(Icons))]
+public partial class PyVideoCueViewModel : CueViewModel, IMediaCue
+{
+ [Reactive] private string path = string.Empty;
+ [Reactive] private string shader = string.Empty;
+ // Timing
+ [Reactive] public TimeSpan startTime;
+ [Reactive, ModelBindsTo(nameof(PyVideoCue.duration))] public TimeSpan playbackDuration;
+ [Reactive] public bool stompsOthers;
+ [Reactive] public float fadeIn = 1;
+ [Reactive] public float fadeOut = 1;
+ [Reactive] public FadeType fadeType = FadeType.SCurve;
+ // Placement
+ [Reactive] public float scale = 1;
+ [Reactive] public float rotation = 0;
+ [Reactive] public Vector2 offset = Vector2.Zero;
+ // Blending
+ [Reactive] public float dimmer = 1;
+ [Reactive] public float volume = 1;
+ [Reactive] private int zIndex;
+ [Reactive] private string? alphaPath;
+ [Reactive] public AlphaMode alphaMode = AlphaMode.Video;
+ // Image
+ [Reactive] public float brightness = 1;
+ [Reactive] public float contrast = 1;
+ [Reactive] public float gamma = 1;
+ // Control
+ [Reactive] public UndoableObservableCollection shaderParameters = [];
+ ///
+ /// DMX address as a single integer (1..508). 0 means unset.
+ ///
+ [Reactive] public int dmxAddress = 0;
+
+ [Reactive, Readonly, ModelSkip] private RelayCommand addShaderParameterCommand;
+ [Reactive, Readonly, ModelSkip] private RelayCommand deleteShaderParameterCommand;
+ [Reactive, Readonly, ModelSkip] private RelayCommand openMediaFileCommand;
+ [Reactive, Readonly, ModelSkip] private RelayCommand openShaderFileCommand;
+ [Reactive, Readonly, ModelSkip] private RelayCommand openAlphaFileCommand;
+ [Reactive, ModelSkip] private readonly ObservableArray alphaModeVals;
+
+ public override string NamePreview => string.IsNullOrEmpty(Name) ? $"Video {fileNameShort}" : Name;
+
+ private string fileNameShort = "NO MEDIA";
+
+ public PyVideoCueViewModel(MainViewModel mainViewModel) : base(mainViewModel)
+ {
+ AddShaderParameterCommand = new(() => shaderParameters.Add(new()));
+ DeleteShaderParameterCommand = new(item =>
+ {
+ if (item == null)
+ return;
+ shaderParameters.Remove(item);
+ });
+ OpenAlphaFileCommand = new(OpenAlphaFileExecute);
+ OpenMediaFileCommand = new(OpenMediaFileExecute);
+ OpenShaderFileCommand = new(OpenShaderFileExecute);
+ alphaModeVals = new(Enum.GetValues());
+
+ PropertyChanged += (o, e) =>
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(Path):
+ try
+ {
+ fileNameShort = System.IO.Path.GetFileNameWithoutExtension(path);
+ }
+ finally
+ {
+ fileNameShort ??= "NO MEDIA";
+ }
+ OnPropertyChanged(nameof(NamePreview));
+ break;
+ }
+ };
+ }
+
+ public Task LoadMediaFiles()
+ {
+ return Task.FromResult(true);
+ //throw new NotImplementedException();
+ }
+
+ public void UnloadMediaFiles()
+ {
+ //throw new NotImplementedException();
+ }
+
+ public void OpenMediaFileExecute()
+ {
+ OpenFileDialog openFileDialog = new()
+ {
+ Multiselect = false,
+ Title = "Open Media File",
+ CheckFileExists = true,
+ FileName = Path,
+ Filter = "Supported Media (*.mp4;*.mkv;*.avi;*.webm;*.flv;*.wmv;*.mov;*.png;*.jpg;*.jpeg;*.bmp;*.exr;*.hdr;*.webp)|*.mp4;*.mkv;*.avi;*.webm;*.flv;*.wmv;*.mov;*.png;*.jpg;*.jpeg;*.bmp;*.exr;*.hdr;*.webp|All files (*.*)|*.*"
+ };
+ if (openFileDialog.ShowDialog() ?? false)
+ {
+ Path = openFileDialog.FileName;
+ }
+ }
+
+ public void OpenShaderFileExecute()
+ {
+ OpenFileDialog openFileDialog = new()
+ {
+ Multiselect = false,
+ Title = "Open Shader File",
+ CheckFileExists = true,
+ FileName = Shader,
+ Filter = "Supported Shaders (*.glsl;*.frag;*.vert)|*.glsl;*.frag;*.vert|All files (*.*)|*.*"
+ };
+ if (openFileDialog.ShowDialog() ?? false)
+ {
+ Shader = openFileDialog.FileName;
+ }
+ }
+
+ public void OpenAlphaFileExecute()
+ {
+ OpenFileDialog openFileDialog = new()
+ {
+ Multiselect = false,
+ Title = "Open Alpha File",
+ CheckFileExists = true,
+ FileName = AlphaPath,
+ Filter = "Supported Media (*.mp4;*.mkv;*.wmv;*.webm;*.png;*.jpg;*.jpeg;*.bmp;*.exr;*.hdr;*.webp)|*.mp4;*.mkv;*.wmv;*.webm;*.png;*.jpg;*.jpeg;*.bmp;*.exr;*.hdr;*.webp|All files (*.*)|*.*"
+ };
+ if (openFileDialog.ShowDialog() ?? false)
+ {
+ AlphaPath = openFileDialog.FileName;
+ }
+ }
+}
+
+public partial class ShaderParameterViewModel : BindableViewModel
+{
+ [Reactive] private string name = string.Empty;
+ [Reactive] private float value;
+}
diff --git a/QPlayer.PyPlayPlugin/QPlayer.PyPlayPlugin.csproj b/QPlayer.PyPlayPlugin/QPlayer.PyPlayPlugin.csproj
new file mode 100644
index 0000000..61091b0
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/QPlayer.PyPlayPlugin.csproj
@@ -0,0 +1,39 @@
+
+
+ net10.0-windows
+ disable
+ enable
+ true
+ true
+
+ QPlayer PyPlay Plugin
+ 0.1.2
+ Thomas Mathieson
+ Thomas Mathieson
+ ©️ Thomas Mathieson 2026
+ https://github.com/space928/QPlayer
+ https://github.com/space928/QPlayer
+ GPL-3.0-or-later
+
+
+
+ true
+
+
+
+
+ false
+ runtime
+
+
+ false
+ Analyzer
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml b/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml
new file mode 100644
index 0000000..a2adc12
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml.cs b/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml.cs
new file mode 100644
index 0000000..20ce8e4
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/ShaderParametersControl.xaml.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Interaction logic for ShaderParametersControl.xaml
+///
+public partial class ShaderParametersControl : UserControl
+{
+ public ShaderParametersControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml b/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml
new file mode 100644
index 0000000..1bedfb8
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml.cs b/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml.cs
new file mode 100644
index 0000000..01fdaa1
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/ShaderParamsCueView.xaml.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace QPlayer.PyPlayPlugin;
+
+///
+/// Interaction logic for ShaderParamsCueView.xaml
+///
+public partial class ShaderParamsCueView : UserControl
+{
+ public ShaderParamsCueView()
+ {
+ InitializeComponent();
+ }
+
+ private void CheckBox_Checked(object sender, RoutedEventArgs e)
+ {
+ if (sender is not CheckBox cb)
+ return;
+
+ TargetQIDField.IsEnabled = !(cb.IsChecked ?? false);
+ }
+}
diff --git a/QPlayer.PyPlayPlugin/ShaderParamsCueViewModel.cs b/QPlayer.PyPlayPlugin/ShaderParamsCueViewModel.cs
new file mode 100644
index 0000000..7231e01
--- /dev/null
+++ b/QPlayer.PyPlayPlugin/ShaderParamsCueViewModel.cs
@@ -0,0 +1,49 @@
+using CommunityToolkit.Mvvm.Input;
+using QPlayer.Audio;
+using QPlayer.Models;
+using QPlayer.SourceGenerator;
+using QPlayer.Utilities;
+using QPlayer.ViewModels;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace QPlayer.PyPlayPlugin;
+
+[Model(typeof(ShaderParamsCue))]
+//[GenerateView]
+[View(typeof(ShaderParamsCueView))]
+[DisplayName("Shader Parameters Cue")]
+[Icon("IconPyShaderCue", typeof(Icons))]
+public partial class ShaderParamsCueViewModel : CueViewModel
+{
+ [Reactive, ModelCustomBinding(nameof(VM2M_TargetQID), nameof(M2VM_TargetQID))] private string targetQid = string.Empty;
+ [Reactive] private UndoableObservableCollection shaderParameters = [];
+ [Reactive] private float fadeTime = 0;
+ [Reactive] private FadeType fadeType = FadeType.SCurve;
+ [Reactive] private bool postProcessing = false;
+
+ [Reactive, Readonly, ModelSkip] private RelayCommand addShaderParameterCommand;
+ [Reactive, Readonly, ModelSkip] private RelayCommand deleteShaderParameterCommand;
+
+ public ShaderParamsCueViewModel(MainViewModel mainViewModel) : base(mainViewModel)
+ {
+ AddShaderParameterCommand = new(() => shaderParameters.Add(new()));
+ DeleteShaderParameterCommand = new(item =>
+ {
+ if (item == null)
+ return;
+ shaderParameters.Remove(item);
+ });
+ }
+
+ private static void M2VM_TargetQID(ShaderParamsCueViewModel vm, ShaderParamsCue m)
+ {
+ vm.PostProcessing = m.targetQid == "post";
+ vm.TargetQid = vm.postProcessing ? string.Empty : m.targetQid;
+ }
+ private static void VM2M_TargetQID(ShaderParamsCueViewModel vm, ShaderParamsCue m)
+ {
+ m.targetQid = vm.postProcessing ? "post" : vm.targetQid;
+ }
+}
diff --git a/QPlayer.SourceGenerator/Included/ReactiveAttribute.cs b/QPlayer.SourceGenerator/Included/ReactiveAttribute.cs
index 77a1650..df634fd 100644
--- a/QPlayer.SourceGenerator/Included/ReactiveAttribute.cs
+++ b/QPlayer.SourceGenerator/Included/ReactiveAttribute.cs
@@ -71,7 +71,7 @@ public sealed class TemplatePropAttribute(string propName) : Attribute
}
///
-/// Marks the setter on the generated property as private.
+/// Marks the setter on the generated property as private. Use this attribute on init-only properties; this has the same effect as the readonly keyword.
///
/// Requires a on this same property.
///
@@ -84,6 +84,12 @@ public sealed class ReadonlyAttribute : Attribute
///
/// Specifies that, for the annotated property, the provided delegates should be called to synchronise
/// data to and from the model for this property.
+///
+/// The signatures of the two methods should look like:
+///
+/// private static void M2VM_Prop(CueViewModel vm, Cue m) => ...
+/// private static void VM2M_Prop(CueViewModel vm, Cue m) => ...
+///
///
/// The name of a static method in this class to copy this property's value from this instance to the model.
/// The name of a static method in this class to copy this property's value from the model to this instance.
@@ -174,7 +180,8 @@ public sealed class ModelAttribute(Type modelType) : Attribute
}
///
-/// Specifies the view type associated with this view model.
+/// Specifies the view type associated with this view model. This is expected to derive from a
+/// or an .
///
///
[System.AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
diff --git a/QPlayer.SourceGenerator/ReactiveObjectGenerator.Emitter.cs b/QPlayer.SourceGenerator/ReactiveObjectGenerator.Emitter.cs
index 427a17c..72bdfd1 100644
--- a/QPlayer.SourceGenerator/ReactiveObjectGenerator.Emitter.cs
+++ b/QPlayer.SourceGenerator/ReactiveObjectGenerator.Emitter.cs
@@ -316,7 +316,7 @@ private static void GenerateBindVM(ReactiveObjectClass model, IndentedStringBuil
sb.AppendIndent();
sb.Append($"case nameof({prop.PropName}): ");
- sb.Append($"{prop.PropName} = ___src.{prop.PropName}; ");
+ sb.Append($"{prop.PropName} = ___src.{prop.PropName}; ");
sb.Append("break;");
sb.AppendLine();
}
@@ -357,7 +357,7 @@ private static void GenerateSyncToModel(ReactiveObjectClass model, IndentedStrin
sb.AppendLine();
foreach (var prop in model.ReactiveFields)
{
- if (prop.BindableParams.SkipBinding || prop.IsReadOnly)
+ if (prop.BindableParams.SkipBinding || (prop.IsReadOnly && !prop.IsBindableVM))
continue;
if (prop.BindableParams.BindingVM2M is string vm2m)
@@ -394,7 +394,7 @@ private static void GenerateSyncFromModel(ReactiveObjectClass model, IndentedStr
sb.AppendLine();
foreach (var prop in model.ReactiveFields)
{
- if (prop.BindableParams.SkipBinding || prop.IsReadOnly)
+ if (prop.BindableParams.SkipBinding || (prop.IsReadOnly && !prop.IsBindableVM))
continue;
if (prop.BindableParams.BindingM2VM is string m2vm)
diff --git a/QPlayer.SourceGenerator/ViewGenerator.Emitter.cs b/QPlayer.SourceGenerator/ViewGenerator.Emitter.cs
index 8f2dc77..eabc977 100644
--- a/QPlayer.SourceGenerator/ViewGenerator.Emitter.cs
+++ b/QPlayer.SourceGenerator/ViewGenerator.Emitter.cs
@@ -168,24 +168,25 @@ private static void CreateControl(IndentedStringBuilder sb, ViewProp prop, ViewC
}
// Bind the value
+ var bindingMode = prop.ReadOnly ? "Mode=OneWay" : "Mode=TwoWay";
if (controlType == "local:TextField")
{
- attrs.Add($"Text='{{Binding {prop.PropName}, UpdateSourceTrigger=Default}}'");
+ attrs.Add($"Text='{{Binding {prop.PropName}, {bindingMode}, UpdateSourceTrigger=Default}}'");
}
else if (prop.EnumValues != null)
{
//
- attrs.Add($"SelectedItem='{{Binding {prop.PropName}}}'");
+ attrs.Add($"SelectedItem='{{Binding {prop.PropName}, {bindingMode}}}'");
attrs.Add($"ItemsSource='{{Binding {prop.PropType}Vals}}'");
}
else
{
- attrs.Add($"Value='{{Binding {prop.PropName}, UpdateSourceTrigger=Default}}'");
+ attrs.Add($"Value='{{Binding {prop.PropName}, {bindingMode}, UpdateSourceTrigger=Default}}'");
}
attrs.Add("Margin='8,2,2,2'");
- if (prop.ReadOnly) // TODO: We also need to set the binding to OneWay
+ if (prop.ReadOnly)
attrs.Add("IsEnabled='False'");
if (prop.FilePickerCmd is string fpCmd)
diff --git a/QPlayer.Tests/CueListTests.cs b/QPlayer.Tests/CueListTests.cs
new file mode 100644
index 0000000..314cce8
--- /dev/null
+++ b/QPlayer.Tests/CueListTests.cs
@@ -0,0 +1,563 @@
+using QPlayer.Models;
+using QPlayer.Utilities;
+using QPlayer.ViewModels;
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Text;
+using TUnit.Assertions;
+using TUnit.Assertions.Should;
+using TUnit.Assertions.Should.Extensions;
+using TUnit.Core.Helpers;
+
+namespace QPlayer.Tests;
+
+public class CueListTests
+{
+ static readonly MainViewModel mainVM = new();
+
+ private const int defaultCount = 10;
+ private static CueViewModel MakeCue() => new DummyCueViewModel(mainVM);
+ private static GroupCueViewModel MakeGroupCue() => new(mainVM);
+ private static GroupCueViewModel MakeGroupCue(VisualCueList owner) => new(mainVM, owner);
+
+ static CueListTests()
+ {
+ // Hack to prevent the undo manager from asserting when being used across threads.
+ for (int i = 0; i < 16; i++)
+ UndoManager.SuppressRecording();
+ }
+
+ private static VisualCueList MakeCueList()
+ {
+ VisualCueList cl = new();
+ List model = [];
+ cl.Bind(model);
+ for (int i = 0; i < defaultCount; i++)
+ {
+ var cue = MakeCue();
+ cue.QID = i;
+ cl.Insert(i, cue);
+ }
+ return cl;
+ }
+
+ private static (VisualCueList, GroupCueViewModel) MakeGroupCueList()
+ {
+ VisualCueList cl = new();
+ List model = [];
+ cl.Bind(model);
+ int i = 0;
+ // Normal cues
+ for (; i < defaultCount - 5; i++)
+ {
+ var cue = MakeCue();
+ cue.QID = i;
+ cl.Insert(i, cue);
+ }
+
+ // A group
+ var group = MakeGroupCue(cl);
+ group.QID = i;
+ cl.Insert(i, group);
+ i++;
+
+ // Fill group
+ int j = 0;
+ for (; i < defaultCount - 1; i++)
+ {
+ var cue = MakeCue();
+ cue.QID = i;
+ cl.Insert(new CueList.CuePosition(j++, group), cue);
+ }
+
+ // Normal cues
+ for (; i < defaultCount; i++)
+ {
+ var cue = MakeCue();
+ cue.QID = i;
+ cl.Insert(i, cue);
+ }
+
+ return (cl, group);
+ }
+
+ private static decimal[] ToQIDs(IEnumerable cues) => cues.Select(x => x.QID).ToArray();
+
+ [Test]
+ public async Task TestMisc()
+ {
+ var cl = MakeCueList();
+
+ await cl.Count.Should().BeEqualTo(defaultCount);
+ await cl.boundModel.Should().NotBeNull();
+ await cl.boundModel!.Count.Should().NotBeZero(); // TODO: more tests for bound model sync
+
+ cl.Clear();
+
+ await cl.Count.Should().BeZero();
+ await cl.boundModel!.Count.Should().BeZero();
+ }
+
+ [Test]
+ public async Task TestGetters()
+ {
+ var (cl, group) = MakeGroupCueList();
+
+ var first = cl[0];
+ await first.QID.Should().BeEqualTo(0);
+
+ await cl.Contains(first).Should().BeTrue();
+ await cl.Contains(MakeCue()).Should().BeFalse();
+
+ var items = ToQIDs(cl.EnumerateAll());
+ await items.Should().HaveCount(defaultCount);
+ await items.Should().BeInOrder();
+
+ items = ToQIDs(cl.EnumerateAllFrom(5));
+ await items.Should().HaveCount(defaultCount - 5);
+ await items.Should().BeInOrder();
+
+ items = ToQIDs(cl.EnumerateVisible());
+ await items.Should().HaveCount(defaultCount);
+ await items.Should().BeInOrder();
+
+ group.IsCollapsed = true;
+ items = ToQIDs(cl.EnumerateVisible());
+ await items.Should().HaveCount(defaultCount - group.Cues.Count);
+ await items.Should().BeInOrder();
+ group.IsCollapsed = false;
+
+ // TODO: These tests all test success conditions and not the failure conditions
+ decimal qid = 9;
+ await cl.Find(qid, out CueList.CuePosition pos).Should().BeTrue();
+ await cl.FindVisualIndex(pos, out int visPos).Should().BeTrue();
+
+ await visPos.Should().BeEqualTo((int)qid);
+ await cl[pos].Should().BeEqualTo(cl[visPos]);
+
+ await cl.Find(qid, out CueViewModel? cue).Should().BeTrue();
+ await cue.Should().BeEqualTo(cl[pos]);
+ await cl.Find(visPos, out var pos2).Should().BeTrue();
+ await pos2.Should().BeEqualTo(pos);
+ await cl.Find(cue!, out var pos3).Should().BeTrue();
+ await pos3.Should().BeEqualTo(pos);
+
+ await cl.FindVisualIndex(cue!, out var visPos2).Should().BeTrue();
+ await visPos2.Should().BeEqualTo(visPos);
+ }
+
+ [Test]
+ public async Task TestDelete()
+ {
+ var (cl, _) = MakeGroupCueList();
+ var startCount = cl.TotalCount;
+
+ await Assert.That(cl.Delete(1)?.QID).IsNotNull().And.IsEqualTo(1);
+ await cl.TotalCount.Should().BeEqualTo(startCount - 1);
+ (cl, _) = MakeGroupCueList();
+ await Assert.That(cl.Delete(new CueList.CuePosition(1, null))?.QID).IsNotNull().And.IsEqualTo(1);
+ await cl.TotalCount.Should().BeEqualTo(startCount - 1);
+ (cl, _) = MakeGroupCueList();
+ await Assert.That(cl.Delete(cl[1])).IsTrue();
+ await cl.TotalCount.Should().BeEqualTo(startCount - 1);
+ (cl, _) = MakeGroupCueList();
+ await Assert.That(cl.Delete(MakeCue())).IsFalse(); // Should fail on a cue that isn't in the list
+ await cl.TotalCount.Should().BeEqualTo(startCount);
+
+ (cl, _) = MakeGroupCueList();
+ await Assert.That(
+ ToQIDs(
+ cl.Delete(cl.Skip(1).Take(3))
+ ))
+ .Count().IsEqualTo(3)
+ .And.IsInOrder()
+ .And.IsEquivalentTo([(decimal)1, 2, 3]);
+ await cl.TotalCount.Should().BeEqualTo(startCount - 3);
+ }
+
+ [Test]
+ public async Task TestDelete_Single_Events()
+ {
+ var (cl, group) = MakeGroupCueList();
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ // Delete by vispos
+ var deleted1 = cl.Delete(1);
+ await deleted1.Should().NotBeNull();
+ await deleted1!.QID.Should().BeEqualTo(1);
+
+ var event1 = events.Last();
+ await event1.Action.Should().BeEqualTo(NotifyCollectionChangedAction.Remove);
+ await event1.OldStartingIndex.Should().BeEqualTo(1);
+ await event1.OldItems![0].Should().BeEqualTo(deleted1);
+
+ await cl.Find(deleted1.QID, out CueViewModel? _).Should().BeFalse();
+
+ // Delete by cuepos
+ var deleted2 = cl.Delete(new CueList.CuePosition(1, null));
+ await deleted1.Should().NotBeNull();
+ await deleted2!.QID.Should().BeEqualTo(2);
+
+ var event2 = events.Last();
+ await event2.Action.Should().BeEqualTo(NotifyCollectionChangedAction.Remove);
+ await event2.OldStartingIndex.Should().BeEqualTo(1);
+ await event2.OldItems![0].Should().BeEqualTo(deleted2);
+
+ await cl.Find(deleted2.QID, out CueViewModel? _).Should().BeFalse();
+ }
+
+ [Test]
+ public async Task TestDelete_Multiple_Events()
+ {
+ var (cl, group) = MakeGroupCueList();
+ var cuesToDelete = cl.Skip(1).Take(3).ToList();
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ var deletedCues = cl.Delete(cuesToDelete);
+
+ await ToQIDs(deletedCues).Should().BeEquivalentTo([(decimal)1, 2, 3]);
+ await ToQIDs(cl.EnumerateAll()).Should().BeEquivalentTo([0m, 4, 5, 6, 7, 8, 9]);
+
+ // Delete() for an enumerable of cues is expected to fire a single reset
+ await events.Count.Should().BeEqualTo(1);
+ var resetEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Reset);
+ await resetEvent.Should().NotBeNull();
+
+ await deletedCues.Should().All(x=> !cl.Find(x.QID, out CueViewModel? _));
+ }
+
+ [Test]
+ public async Task TestDelete_Group()
+ {
+ var (cl, group) = MakeGroupCueList();
+ var startCount = cl.TotalCount;
+
+ // Ensure we know exactly where the group is
+ int groupVisPos = cl.FindVisualIndex(group);
+ await cl[groupVisPos].Should().BeEqualTo(group);
+
+ var groupCues = new OneEnumerable(group).Concat(group.Cues).ToArray();
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ bool deleted = cl.Delete(group);
+ await deleted.Should().BeTrue();
+
+ // We should have a single Remove event with the group's cues
+ var removeEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Remove);
+ await removeEvent.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await removeEvent!.OldStartingIndex.Should().BeEqualTo(groupVisPos);
+ await removeEvent.OldItems!.Count.Should().BeEqualTo(groupCues.Length);
+ await removeEvent.OldItems[0].Should().BeEqualTo(group);
+ await ToQIDs(removeEvent.OldItems.Cast()).Should().BeEquivalentTo(ToQIDs(groupCues));
+
+ await cl.TotalCount.Should().BeEqualTo(startCount - removeEvent.OldItems!.Count);
+ await groupCues.Should().All(x => !cl.Find(x.QID, out CueViewModel? _));
+ }
+
+ [Test]
+ public async Task TestDelete_Group_Collapsed()
+ {
+ var (cl, group) = MakeGroupCueList();
+ var startCount = cl.TotalCount;
+
+ // Ensure we know exactly where the group is
+ int groupVisPos = cl.FindVisualIndex(group);
+ await cl[groupVisPos].Should().BeEqualTo(group);
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ var groupContents = group.Cues.ToArray();
+ var groupCues = new OneEnumerable(group).Concat(groupContents).ToArray();
+
+ // Collapse the group
+ group.IsCollapsed = true;
+
+ // Collapsing visually removes the children starting from the group's index + 1
+ var removeEvent1 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Remove);
+ await removeEvent1.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await removeEvent1!.OldStartingIndex.Should().BeEqualTo(groupVisPos + 1);
+ await removeEvent1.OldItems!.Count.Should().BeEqualTo(groupContents.Length);
+
+ await cl.Count.Should().BeEqualTo(startCount - groupContents.Length);
+ await cl.TotalCount.Should().BeEqualTo(startCount);
+
+ // Delete the collapsed group
+ events.Clear();
+
+ bool deleted = cl.Delete(group);
+ await deleted.Should().BeTrue();
+
+ // The delete event should ONLY contain the group cue now, as children are already hidden
+ var removeEvent2 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Remove);
+ await removeEvent2.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await removeEvent2!.OldStartingIndex.Should().BeEqualTo(groupVisPos);
+ await removeEvent2.OldItems!.Count.Should().BeEqualTo(1);
+ await removeEvent2.OldItems[0].Should().BeEqualTo(group);
+
+ await cl.Count.Should().BeEqualTo(startCount - groupCues.Length);
+ await cl.TotalCount.Should().BeEqualTo(startCount - groupCues.Length);
+ }
+
+ [Test]
+ public async Task TestInsert_Single_Events()
+ {
+ var (cl, group) = MakeGroupCueList();
+ int startCount = cl.TotalCount;
+ int targetIndex = 2;
+
+ var singleCue = MakeCue();
+ singleCue.QID = 99;
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ // Insert
+ int insertedVisPos = cl.Insert(targetIndex, singleCue);
+
+ // Check state
+ await insertedVisPos.Should().BeEqualTo(targetIndex);
+ await cl[targetIndex].QID.Should().BeEqualTo(99);
+ await cl.TotalCount.Should().BeEqualTo(startCount + 1);
+
+ // Check events
+ var addEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent!.NewStartingIndex.Should().BeEqualTo(targetIndex);
+ await addEvent.NewItems!.Count.Should().BeEqualTo(1);
+ await addEvent.NewItems[0].Should().BeEqualTo(singleCue);
+
+ await cl.Find(singleCue.QID, out CueViewModel? _).Should().BeTrue();
+ }
+
+ [Test]
+ public async Task TestInsert_Multiple_Events()
+ {
+ var (cl, group) = MakeGroupCueList();
+ int startCount = cl.TotalCount;
+ int targetIndex = 5;
+
+ var newCue1 = MakeCue(); newCue1.QID = 101;
+ var newCue2 = MakeCue(); newCue2.QID = 102;
+ var newCues = new[] { newCue1, newCue2 };
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ // Insert multiple
+ int firstInsertedVisPos = cl.Insert(targetIndex, newCues);
+
+ // Check state
+ await firstInsertedVisPos.Should().BeEqualTo(targetIndex);
+ await cl[targetIndex].QID.Should().BeEqualTo(101);
+ await cl[targetIndex + 1].QID.Should().BeEqualTo(102);
+ await cl.TotalCount.Should().BeEqualTo(startCount + 2);
+
+ // Check events
+ var addEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent!.NewStartingIndex.Should().BeEqualTo(targetIndex);
+ await addEvent.NewItems!.Count.Should().BeEqualTo(2);
+ await addEvent.NewItems[0].Should().BeEqualTo(newCue1);
+ await addEvent.NewItems[1].Should().BeEqualTo(newCue2);
+ }
+
+ [Test]
+ public async Task TestInsert_MultipleInds_Events()
+ {
+ var (cl, group) = MakeGroupCueList();
+ int startCount = cl.TotalCount;
+
+ var newCue1 = MakeCue(); newCue1.QID = 101;
+ var newCue2 = MakeCue(); newCue2.QID = 102;
+ var newCues = new[] { newCue1, newCue2 };
+
+ int[] targetIndices = [1, 4];
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ // Insert multiple
+ int[] insertedVisPos = cl.Insert(targetIndices, newCues);
+
+ // Check state
+ await insertedVisPos.Should().HaveCount(2);
+ await cl.TotalCount.Should().BeEqualTo(startCount + 2);
+
+ // They should both have been inserted in the correct order
+ await cl[insertedVisPos[0]].QID.Should().BeEqualTo(101);
+ await cl[insertedVisPos[1]].QID.Should().BeEqualTo(102);
+
+ // Inserting multiple items at different indexes should trigger a reset event
+ var resetEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Reset);
+ await resetEvent.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await events.Any(e => e.Action == NotifyCollectionChangedAction.Add).Should().BeFalse();
+
+ await newCues.Should().All(x => cl.Find(x.QID, out CueViewModel? _));
+ }
+
+ [Test]
+ public async Task TestInsert_Group_Collapsed()
+ {
+ var (cl, group) = MakeGroupCueList();
+ var startCount = cl.TotalCount;
+
+ // Ensure we know exactly where the group is
+ int groupVisPos = cl.FindVisualIndex(group);
+ await cl[groupVisPos].Should().BeEqualTo(group);
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ var groupContents = group.Cues.ToArray();
+ var groupCues = new OneEnumerable(group).Concat(groupContents).ToArray();
+
+ // Collapse the group
+ group.IsCollapsed = true;
+
+ // Delete the collapsed group
+ bool deleted = cl.Delete(group);
+ await deleted.Should().BeTrue();
+
+ // These events are checked by another test
+ events.Clear();
+
+ // Now reinsert the group and check that we get all the right insert messages
+ cl.Insert(2, group);
+
+ // The insert event should ONLY contain the group cue now, as children are already hidden
+ var addEvent1 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent1.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent1!.NewStartingIndex.Should().BeEqualTo(2);
+ await addEvent1.NewItems!.Count.Should().BeEqualTo(1);
+ await addEvent1.NewItems[0].Should().BeEqualTo(group);
+
+ await cl.Count.Should().BeEqualTo(startCount - groupContents.Length);
+ await cl.TotalCount.Should().BeEqualTo(startCount);
+
+ await groupCues.Should().All(x => cl.Find(x.QID, out CueViewModel? _));
+
+ events.Clear();
+
+ // Uncollapse the group and check we get the right events
+ group.IsCollapsed = false;
+
+ var addEvent2 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent2.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent2!.NewStartingIndex.Should().BeEqualTo(3);
+ await addEvent2.NewItems!.Count.Should().BeEqualTo(groupContents.Length);
+ await addEvent2.NewItems[0].Should().BeEqualTo(groupContents[0]);
+
+ await cl.Count.Should().BeEqualTo(startCount);
+ await cl.TotalCount.Should().BeEqualTo(startCount);
+ }
+
+ [Test]
+ public async Task TestInsert_Group_Nested()
+ {
+ /*var (cl, group) = MakeGroupCueList();
+ var startCount = cl.TotalCount;
+
+ // Ensure we know exactly where the group is
+ int groupVisPos = cl.FindVisualIndex(group);
+ await cl[groupVisPos].Should().BeEqualTo(group);
+
+ List events = [];
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ var newGroup = MakeGroupCue(cl);
+ newGroup.Cues.Insert(0, MakeCue());
+ newGroup.Cues.Insert(1, MakeCue());
+ newGroup.Cues[0].QID = 101;
+ newGroup.Cues[0].QID = 102;
+
+ var groupContents = group.Cues.ToArray();
+ var groupCues = new OneEnumerable(group).Concat(groupContents).ToArray();
+
+ // Collapse the group
+ group.IsCollapsed = true;
+
+ events.Clear();
+
+ // Now insert the new group and check that we get all the right insert messages
+ cl.Insert(2, group);
+
+ // The insert event should ONLY contain the group cue now, as children are already hidden
+ var addEvent1 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent1.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent1!.NewStartingIndex.Should().BeEqualTo(2);
+ await addEvent1.NewItems!.Count.Should().BeEqualTo(1);
+ await addEvent1.NewItems[0].Should().BeEqualTo(group);
+
+ await cl.Count.Should().BeEqualTo(startCount - groupContents.Length);
+ await cl.TotalCount.Should().BeEqualTo(startCount);
+
+ await groupCues.Should().All(x => cl.Find(x.QID, out CueViewModel? _));
+
+ events.Clear();
+
+ // Uncollapse the group and check we get the right events
+ group.IsCollapsed = false;
+
+ var addEvent2 = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Add);
+ await addEvent2.Should().NotBeNull();
+ await events.Count.Should().BeEqualTo(1);
+ await addEvent2!.NewStartingIndex.Should().BeEqualTo(3);
+ await addEvent2.NewItems!.Count.Should().BeEqualTo(groupContents.Length);
+ await addEvent2.NewItems[0].Should().BeEqualTo(groupContents[0]);
+
+ await cl.Count.Should().BeEqualTo(startCount);
+ await cl.TotalCount.Should().BeEqualTo(startCount);*/
+ }
+
+ [Test]
+ public async Task TestCuePositionComparer()
+ {
+ var comparer = new VisualCueList.CuePositionComparer();
+ var group = MakeGroupCue();
+
+ var posNullGroup = new CueList.CuePosition(0, null);
+ var posWithGroup = new CueList.CuePosition(0, group);
+ var posNullGroupHigherIndex = new CueList.CuePosition(1, null);
+
+ // x.group == null && y.group != null should return 1
+ await comparer.Compare(posNullGroup, posWithGroup).Should().BeGreaterThan(0);
+
+ // y.group == null && x.group != null should return -1
+ await comparer.Compare(posWithGroup, posNullGroup).Should().BeLessThan(0);
+
+ await comparer.Compare(posNullGroup, posNullGroupHigherIndex).Should().BeLessThan(0);
+ await comparer.Compare(posNullGroupHigherIndex, posNullGroup).Should().BeGreaterThan(0);
+ }
+
+ [Test]
+ public async Task TestClear_Events()
+ {
+ var cl = MakeCueList();
+
+ var events = new List();
+ cl.CollectionChanged += (s, e) => events.Add(e);
+
+ cl.Clear();
+
+ var resetEvent = events.FirstOrDefault(e => e.Action == NotifyCollectionChangedAction.Reset);
+ await resetEvent.Should().NotBeNull();
+ await events.Any(e => e.Action == NotifyCollectionChangedAction.Remove).Should().BeFalse();
+ }
+}
diff --git a/QPlayer.Tests/QPlayer.Tests.csproj b/QPlayer.Tests/QPlayer.Tests.csproj
new file mode 100644
index 0000000..c149192
--- /dev/null
+++ b/QPlayer.Tests/QPlayer.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ enable
+ enable
+ Exe
+ net10.0-windows
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.sln b/QPlayer.sln
index 9e33f76..389ae4f 100644
--- a/QPlayer.sln
+++ b/QPlayer.sln
@@ -24,6 +24,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QPlayer.MagicQCTRLPlugin",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarlightDocNet", "StarlightDocNet\StarlightDocNet.csproj", "{C3B27B5B-C834-4271-A8D2-9C31BEA020E7}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QPlayer.Tests", "QPlayer.Tests\QPlayer.Tests.csproj", "{5B65372E-6A62-1134-BAC6-CBF4A7E3160A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QPlayer.PyPlayPlugin", "QPlayer.PyPlayPlugin\QPlayer.PyPlayPlugin.csproj", "{CDE74ED8-3082-4DB4-B5EE-7EDA13848319}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -168,6 +172,46 @@ Global
{C3B27B5B-C834-4271-A8D2-9C31BEA020E7}.Release|x64.Build.0 = Release|Any CPU
{C3B27B5B-C834-4271-A8D2-9C31BEA020E7}.Release|x86.ActiveCfg = Release|Any CPU
{C3B27B5B-C834-4271-A8D2-9C31BEA020E7}.Release|x86.Build.0 = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|ARM.Build.0 = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|x64.Build.0 = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Debug|x86.Build.0 = Debug|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|ARM.ActiveCfg = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|ARM.Build.0 = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|ARM64.Build.0 = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|x64.ActiveCfg = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|x64.Build.0 = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|x86.ActiveCfg = Release|Any CPU
+ {5B65372E-6A62-1134-BAC6-CBF4A7E3160A}.Release|x86.Build.0 = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|ARM.Build.0 = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|x64.Build.0 = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Debug|x86.Build.0 = Debug|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|ARM.ActiveCfg = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|ARM.Build.0 = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|ARM64.Build.0 = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|x64.ActiveCfg = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|x64.Build.0 = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|x86.ActiveCfg = Release|Any CPU
+ {CDE74ED8-3082-4DB4-B5EE-7EDA13848319}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/QPlayer/App.xaml b/QPlayer/App.xaml
index fb9ef89..252065b 100644
--- a/QPlayer/App.xaml
+++ b/QPlayer/App.xaml
@@ -7,13 +7,7 @@
-
-
-
-
-
-
-
+
pack://application:,,,/Resources/#Cascadia Mono
diff --git a/QPlayer/AssemblyInfo.cs b/QPlayer/AssemblyInfo.cs
index 8b5504e..60d20df 100644
--- a/QPlayer/AssemblyInfo.cs
+++ b/QPlayer/AssemblyInfo.cs
@@ -1,3 +1,4 @@
+using System.Runtime.CompilerServices;
using System.Windows;
[assembly: ThemeInfo(
@@ -8,3 +9,4 @@
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
+[assembly: InternalsVisibleTo("QPlayer.Tests")]
diff --git a/QPlayer/Audio/AudioPlaybackManager.cs b/QPlayer/Audio/AudioPlaybackManager.cs
index fb4b3be..d154534 100644
--- a/QPlayer/Audio/AudioPlaybackManager.cs
+++ b/QPlayer/Audio/AudioPlaybackManager.cs
@@ -125,17 +125,26 @@ private void CloseAudioDevices()
}
catch { }
// Wait for the device to finish playing...
- deviceClosedEvent.Wait(200);
+ while (device != null && device.PlaybackState == PlaybackState.Playing)
+ deviceClosedEvent.Wait(10);
device?.Dispose();
device = null;
if (synchronizationContext != null)
- synchronizationContext.Post(_ => DeviceStateChanged?.Invoke(false), null);
+ synchronizationContext.Send(_ => DeviceStateChanged?.Invoke(false), null);
else
DeviceStateChanged?.Invoke(false);
}
private void DevicePlaybackStopped(object? sender, StoppedEventArgs e)
{
+ // NAudio tries to be clever and dispatches this callback through the sync context for us.
+ // The issue with this is we may have already opened a new audio device by the time this
+ // message arrives (it's tricky to wait for the callback as it all happens in the main
+ // thread). As such, if a new device has been set by the time we get this callback, then
+ // we just ignore the callback.
+ if (device != sender)
+ return;
+
if (e.Exception != null)
{
MainViewModel.Log($"Audio device error! \n{e.Exception}", MainViewModel.LogLevel.Error);
diff --git a/QPlayer/Models/PluginAttributes.cs b/QPlayer/Models/PluginAttributes.cs
index 95911f9..e7b196c 100644
--- a/QPlayer/Models/PluginAttributes.cs
+++ b/QPlayer/Models/PluginAttributes.cs
@@ -37,7 +37,7 @@ public sealed class PluginDescriptionAttribute(string description) : Attribute
///
/// Creates a main menu item which invokes this method when clicked.
///
-/// Only applicable to parameterless methods and properties on the class implementing .
+/// Only applicable to parameterless methods and properties on the class implementing .
///
/// The path to the menu item to be created, eg: 'File/Save'
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
diff --git a/QPlayer/Models/PluginLoader.cs b/QPlayer/Models/PluginLoader.cs
index dee16d7..ac8b1dc 100644
--- a/QPlayer/Models/PluginLoader.cs
+++ b/QPlayer/Models/PluginLoader.cs
@@ -1,4 +1,5 @@
-using QPlayer.Utilities;
+using CommunityToolkit.Mvvm.Input;
+using QPlayer.Utilities;
using QPlayer.ViewModels;
using System;
using System.Collections.Generic;
@@ -9,6 +10,7 @@
using System.Runtime.Loader;
using System.Text;
using System.Threading.Tasks;
+using System.Windows.Input;
using static QPlayer.ViewModels.CueFactory;
namespace QPlayer.Models;
@@ -69,7 +71,23 @@ public static void LoadPlugins(MainViewModel mainViewModel)
string name = pluginType.GetCustomAttribute()?.Name ?? pluginAssembly.FullName ?? fname;
string description = pluginType.GetCustomAttribute()?.Description ?? "No description provided.";
- loadedPlugins.Add(pluginAssembly, new(name, author, version, description, pluginAssembly, pluginInst, cueTypes));
+ using var menuItems = new TemporaryList();
+ foreach (var prop in pluginType.GetProperties())
+ {
+ if (prop.GetCustomAttribute() is MenuItemAttribute menu
+ && prop.PropertyType.IsAssignableTo(typeof(ICommand)))
+ menuItems.Add(new(menu.Path, (ICommand)prop.GetValue(pluginInst)!));
+ }
+ foreach (var meth in pluginType.GetMethods())
+ {
+ if (meth.GetCustomAttribute() is MenuItemAttribute menu)
+ {
+ var command = new RelayCommand(meth.CreateDelegate(pluginInst));
+ menuItems.Add(new(menu.Path, command));
+ }
+ }
+
+ loadedPlugins.Add(pluginAssembly, new(name, author, version, description, pluginAssembly, pluginInst, cueTypes, menuItems.ToArray()));
pluginInst?.OnLoad(mainViewModel);
}
@@ -110,7 +128,8 @@ internal static void OnSlowUpdate()
}
public readonly struct LoadedPlugin(string name, string author, string version, string description,
- Assembly assembly, QPlayerPlugin? pluginInst, RegisteredCueType[] registeredCueTypes)
+ Assembly assembly, QPlayerPlugin? pluginInst, RegisteredCueType[] registeredCueTypes,
+ PluginMenuItem[] pluginMenuItems)
{
public readonly string Name = name;
public readonly string Author = author;
@@ -119,6 +138,13 @@ public readonly struct LoadedPlugin(string name, string author, string version,
public readonly Assembly assembly = assembly;
public readonly QPlayerPlugin? pluginInst = pluginInst;
public readonly RegisteredCueType[] registeredCueTypes = registeredCueTypes;
+ public readonly PluginMenuItem[] pluginMenuItems = pluginMenuItems;
+ }
+
+ public readonly struct PluginMenuItem(string path, ICommand command)
+ {
+ public readonly string path = path;
+ public readonly ICommand command = command;
}
}
diff --git a/QPlayer/Models/ShowFile.cs b/QPlayer/Models/ShowFile.cs
index c52c4ba..f9e1e7a 100644
--- a/QPlayer/Models/ShowFile.cs
+++ b/QPlayer/Models/ShowFile.cs
@@ -2,14 +2,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.Drawing;
using System.Numerics;
namespace QPlayer.Models;
public record ShowFile
{
- public const int FILE_FORMAT_VERSION = 7;
+ public const int FILE_FORMAT_VERSION = 8;
public int fileFormatVersion = FILE_FORMAT_VERSION;
public ShowSettings showSettings = new();
@@ -91,11 +90,18 @@ public enum TriggerMode
AfterLast
}
+public enum GroupTriggerMode
+{
+ Next,
+ All,
+ Shuffle
+}
+
public record Cue
{
//public CueType type;
public decimal qid;
- public decimal? parent;
+ public string? parent;
public SerializedColour colour = SerializedColour.Black;
public string name = string.Empty;
public string description = string.Empty;
@@ -106,11 +112,17 @@ public record Cue
public LoopMode loopMode;
public int loopCount = 1;
public string remoteNode = string.Empty;
+
+ public Cue() : base() { }
}
public record GroupCue : Cue
{
public GroupCue() : base() { }
+
+ public List cues = [];
+ public GroupTriggerMode groupTrigger;
+ public bool isCollapsed;
}
public record DummyCue : Cue
@@ -143,7 +155,7 @@ public TimeCodeCue() : base() { }
public record StopCue : Cue
{
- public decimal stopQid;
+ public string stopQid = string.Empty;
public StopMode stopMode;
public float fadeOutTime;
public FadeType fadeType = FadeType.SCurve;
@@ -153,7 +165,7 @@ public StopCue() : base() { }
public record VolumeCue : Cue
{
- public decimal soundQid;
+ public string soundQid = string.Empty;
public float fadeTime;
public float volume;
public FadeType fadeType = FadeType.SCurve;
diff --git a/QPlayer/Models/ShowFileConverter.cs b/QPlayer/Models/ShowFileConverter.cs
index 58a8c02..1880cf7 100644
--- a/QPlayer/Models/ShowFileConverter.cs
+++ b/QPlayer/Models/ShowFileConverter.cs
@@ -142,6 +142,8 @@ public static void UpgradeShowFile(ShowFile showFile, JsonDocument json)
UpgradeV3ToV4(showFile, json);
if (showFile.fileFormatVersion < 7)
UpgradeV6ToV7(showFile, json);
+ if (showFile.fileFormatVersion < 8)
+ UpgradeV7ToV8(showFile, json);
}
private static void UpgradeV2ToV3(ShowFile showFile, JsonDocument json)
@@ -277,6 +279,70 @@ private static void UpgradeV6ToV7(ShowFile showFile, JsonDocument json)
volCue.volume = 20 * MathF.Log10(volCue.volume);
}
+ private static void UpgradeV7ToV8(ShowFile showFile, JsonDocument json)
+ {
+ MainViewModel.Log($"Upgrading show file from V7 to V8...", MainViewModel.LogLevel.Info);
+
+ if (json.RootElement.ValueKind != JsonValueKind.Object)
+ return;
+
+ foreach (var field in json.RootElement.EnumerateObject())
+ {
+ switch (field.Name)
+ {
+ case nameof(ShowFile.cues):
+ if (field.Value.ValueKind == JsonValueKind.Array)
+ {
+ int i = 0;
+ foreach (var cue in field.Value.EnumerateArray())
+ {
+ if (cue.ValueKind == JsonValueKind.Object)
+ {
+ var cueLoaded = showFile.cues[i];
+ UpgradeCue(cueLoaded, cue);
+ i++;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ static void UpgradeCue(Cue cue, JsonElement json)
+ {
+ foreach (var field in json.EnumerateObject())
+ {
+ if (field.Name == "parent" && field.Value.ValueKind == JsonValueKind.Number)
+ {
+ cue.parent = field.Value.GetDecimal().ToString(MainViewModel.numberFormat);
+ break;
+ }
+ }
+ if (cue is StopCue stop)
+ {
+ foreach (var field in json.EnumerateObject())
+ {
+ if (field.Name == "stopQid" && field.Value.ValueKind == JsonValueKind.Number)
+ {
+ stop.stopQid = field.Value.GetDecimal().ToString(MainViewModel.numberFormat);
+ break;
+ }
+ }
+ }
+ else if (cue is VolumeCue vol)
+ {
+ foreach (var field in json.EnumerateObject())
+ {
+ if (field.Name == "soundQid" && field.Value.ValueKind == JsonValueKind.Number)
+ {
+ vol.soundQid = field.Value.GetDecimal().ToString(MainViewModel.numberFormat);
+ break;
+ }
+ }
+ }
+ }
+ }
+
private static ShowSettings LoadShowSettingsSafe(JsonElement json)
{
ShowSettings settings = new();
@@ -302,7 +368,7 @@ private static ShowSettings LoadShowSettingsSafe(JsonElement json)
private static Cue LoadCueSafe(JsonElement json)
{
- Cue cue = new();
+ Cue cue = CueFactory.CreateCue(nameof(DummyCue))!;
// Determine the cue type
foreach (var field in json.EnumerateObject())
@@ -312,16 +378,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/QPlayer.csproj b/QPlayer/QPlayer.csproj
index 8079ec5..288def5 100644
--- a/QPlayer/QPlayer.csproj
+++ b/QPlayer/QPlayer.csproj
@@ -6,7 +6,7 @@
enable
true
QPlayer
- 1.12.4
+ 1.12.7
Thomas Mathieson
Thomas Mathieson
©️ Thomas Mathieson 2026
@@ -86,15 +86,11 @@
-
+
-
+
-
-
-
-
-
+
@@ -119,4 +115,10 @@
-->
+
+
+ MSBuild:Compile
+
+
+
diff --git a/QPlayer/Resources/Icons/ConvertedIcons.xaml b/QPlayer/Resources/Icons/ConvertedIcons.xaml
index ecfc334..3801ee1 100644
Binary files a/QPlayer/Resources/Icons/ConvertedIcons.xaml and b/QPlayer/Resources/Icons/ConvertedIcons.xaml differ
diff --git a/QPlayer/Resources/Icons/Update.ps1 b/QPlayer/Resources/Icons/Update.ps1
index 5d56335..6173778 100644
--- a/QPlayer/Resources/Icons/Update.ps1
+++ b/QPlayer/Resources/Icons/Update.ps1
@@ -3,5 +3,5 @@
&".\SvgToXaml\SvgToXaml.exe" BuildDict /inputdir:. /outputdir:. /outputname ConvertedIcons /nameprefix "Icon" /buildhtmlfile=false
Read-Host
-(Get-Content .\ConvertedIcons.xaml) -replace 'Brush=\"#.*?\"','Brush="{StaticResource IconColor}"' | Out-File .\ConvertedIcons.xaml
+(Get-Content .\ConvertedIcons.xaml) -replace 'Brush=\"#.*?\"','Brush="{DynamicResource IconColor}"' | Out-File .\ConvertedIcons.xaml
Write-Output "Updated icons!"
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/ThemesV2/Controls.xaml b/QPlayer/ThemesV2/Controls.xaml
index 12df472..b0a8a4a 100644
--- a/QPlayer/ThemesV2/Controls.xaml
+++ b/QPlayer/ThemesV2/Controls.xaml
@@ -15,22 +15,6 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
+
@@ -2731,7 +2733,7 @@
-
+
@@ -2743,7 +2745,7 @@
-
+
@@ -3081,7 +3083,7 @@
-
+
diff --git a/QPlayer/ThemesV2/SoftDark.xaml b/QPlayer/ThemesV2/SoftDark.xaml
index 7529cd7..2a4ae41 100644
--- a/QPlayer/ThemesV2/SoftDark.xaml
+++ b/QPlayer/ThemesV2/SoftDark.xaml
@@ -9,6 +9,7 @@
+
diff --git a/QPlayer/Utilities/ExtensionMethods.cs b/QPlayer/Utilities/ExtensionMethods.cs
index 60a4e60..50ee5d1 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.
@@ -233,5 +237,82 @@ public static void AddRange(this PointCollection collection, IEnumerable
///
///
- public static IEnumerable FastReverse(this IEnumerable source) => new FastReverseEnumerable(source);
+ public static IEnumerable FastReverse(this IEnumerable source)
+ {
+ if (source is IList list)
+ return new FastReverseList(list);
+ return new FastReverseEnumerable(source);
+ }
+
+ ///
+ public static FastReverseList FastReverse(this IList source) => new(source);
+
+ public static Span AsSpan(this List source, int start = 0, int count = -1)
+ {
+ var span = CollectionsMarshal.AsSpan(source);
+ if (start > 0)
+ span = span[start..];
+ if (count > -1)
+ span = span[..count];
+ return span;
+ }
+
+ /*///
+ /// Returns the first element in a collection or the default value if it's empty.
+ ///
+ ///
+ ///
+ ///
+ public static T? FirstOrDefault(this IList source)
+ {
+ if (source.Count == 0)
+ return default;
+
+ return source[0];
+ }
+
+ ///
+ public static T? FirstOrDefault(this ICollection source)
+ {
+ if (source.Count == 0)
+ return default;
+
+ using var iter = source.GetEnumerator();
+ if (iter.MoveNext())
+ return iter.Current;
+ return default;
+ }*/
+
+ ///
+ /// Returns the first element in a collection or the default value if it's empty.
+ ///
+ ///
+ ///
+ ///
+ public static T? FirstOrDefault(this IReadOnlyList source)
+ {
+ if (source.Count == 0)
+ return default;
+
+ return source[0];
+ }
+
+ ///
+ public static T? FirstOrDefault(this IReadOnlyCollection source)
+ {
+ if (source.Count == 0)
+ return default;
+
+ using var iter = source.GetEnumerator();
+ if (iter.MoveNext())
+ return iter.Current;
+
+ return default;
+ }
+
+ ///
+ public static IEnumerable<(TA first, TB second)> FastZip(this IEnumerable first, IEnumerable second) => new FastZipEnumerable(first, second);
+ public static IReadOnlyList<(TA first, TB second)> FastZip(this IReadOnlyList first, IReadOnlyList second) => new FastZipList(first, second);
+ ///
+ public static TemporaryList.TempListZipEnumerable FastZip(this in TemporaryList first, in TemporaryList second) => new(in first, in second);
}
diff --git a/QPlayer/Utilities/FastReverseIterator.cs b/QPlayer/Utilities/FastReverseIterator.cs
index b2c34e5..d432cc2 100644
--- a/QPlayer/Utilities/FastReverseIterator.cs
+++ b/QPlayer/Utilities/FastReverseIterator.cs
@@ -2,20 +2,47 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text;
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)
+ {
+ int count = Count; // This risks enumerating the collection twice...
+ if (arrayIndex + count > array.Length)
+ throw new ArgumentOutOfRangeException(nameof(array));
+
+ var iter = source.GetEnumerator();
+ for (int i = arrayIndex + count - 1; i >= arrayIndex; i--)
+ {
+ if (!iter.MoveNext())
+ break;
+ array[i] = iter.Current;
+ }
+ }
+
+ 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 +51,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
{
@@ -54,3 +87,96 @@ public void Reset()
pos = len;
}
}
+
+///
+/// This is effecitvely a wrapper for a list which reverses the items within it without re-ordering the data.
+///
+///
+///
+public readonly struct FastReverseList(IList src) : IGeneralList, IList
+{
+ public readonly T this[int index] { get => src[ConvertIndex(index)]; set => src[ConvertIndex(index)] = value; }
+ readonly object? IList.this[int index] { get => src[ConvertIndex(index)]; set => src[ConvertIndex(index)] = (T)value!; }
+ public readonly int Count => src.Count;
+ public readonly bool IsReadOnly => src.IsReadOnly;
+ public readonly bool IsFixedSize => true;
+ public readonly bool IsSynchronized => false;
+ public readonly object SyncRoot => new();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private readonly int ConvertIndex(int index) => Count - index - 1;
+
+ public readonly int Add(object? value)
+ {
+ if (value is T item)
+ {
+ src.Insert(0, item);
+ return Count - 1;
+ }
+ return -1;
+ }
+ public readonly void CopyTo(T[] array, int arrayIndex)
+ {
+ src.CopyTo(array, arrayIndex);
+ Array.Reverse(array, arrayIndex, Count);
+ }
+ public readonly void CopyTo(Array array, int index)
+ {
+ src.CopyTo((T[])array, index);
+ Array.Reverse(array, index, Count);
+ }
+ public readonly int IndexOf(T item)
+ {
+ int ind = src.IndexOf(item);
+ if (ind < 0)
+ return ind;
+
+ return ConvertIndex(ind);
+ }
+
+ public readonly void Insert(int index, T item) => src.Insert(ConvertIndex(index), item);
+ public readonly void RemoveAt(int index) => src.RemoveAt(ConvertIndex(index));
+
+ public readonly void Add(T item) => src.Insert(0, item);
+ public readonly void Clear() => src.Clear();
+ public readonly bool Contains(T item) => src.Contains(item);
+ public readonly bool Contains(object? value) => value is T item && src.Contains(item);
+ public readonly IEnumerator GetEnumerator() => new FastReverseListIterator(src);
+ public readonly int IndexOf(object? value) => value is T item ? IndexOf(item) : -1;
+ public readonly void Insert(int index, object? value) => Insert(index, (T)value!);
+ public readonly bool Remove(T item) => src.Remove(item);
+ public readonly void Remove(object? value) => src.Remove((T)value!);
+ readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public struct FastReverseListIterator : IEnumerator
+ {
+ private readonly IList source;
+ private readonly int len;
+ private int pos;
+
+ public readonly T Current => source[pos];
+
+ readonly object? IEnumerator.Current => Current;
+
+ public readonly int Count => source?.Count ?? 0;
+
+ public FastReverseListIterator(IList source)
+ {
+ this.source = source;
+ len = pos = source.Count;
+ }
+
+ public readonly void Dispose() { }
+
+ public bool MoveNext()
+ {
+ pos--;
+ return pos >= 0;
+ }
+
+ public void Reset()
+ {
+ pos = len;
+ }
+ }
+}
diff --git a/QPlayer/Utilities/FastZipIterator.cs b/QPlayer/Utilities/FastZipIterator.cs
new file mode 100644
index 0000000..b57cfe4
--- /dev/null
+++ b/QPlayer/Utilities/FastZipIterator.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace QPlayer.Utilities;
+
+///
+/// A very simple enumerable that implements
+/// without any allocations.
+///
+///
+///
+///
+///
+internal readonly struct FastZipEnumerable(IEnumerable first, IEnumerable second) : IEnumerable<(TA first, TB second)>
+{
+ public IEnumerator<(TA first, TB second)> GetEnumerator() => new FastZipIterator(first, second);
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ internal readonly struct FastZipIterator(IEnumerable first, IEnumerable second) : IEnumerator<(TA first, TB second)>
+ {
+ private readonly IEnumerator first = first.GetEnumerator();
+ private readonly IEnumerator second = second.GetEnumerator();
+
+ public readonly (TA first, TB second) Current => (first.Current, second.Current);
+
+ readonly object IEnumerator.Current => Current;
+
+ public readonly void Dispose()
+ {
+ first.Dispose();
+ second.Dispose();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public readonly bool MoveNext() => first.MoveNext() && second.MoveNext();
+
+ public readonly void Reset()
+ {
+ first.Reset();
+ second.Reset();
+ }
+ }
+}
+
+///
+/// A very simple enumerable that implements
+/// without any allocations.
+///
+///
+///
+///
+///
+internal readonly struct FastZipList(IReadOnlyList first, IReadOnlyList second) : IReadOnlyList<(TA first, TB second)>, IList<(TA first, TB second)>
+{
+ public (TA first, TB second) this[int index]
+ {
+ get => (first[index], second[index]);
+ set => throw new InvalidOperationException();
+ }
+
+ public int Count => Math.Min(first.Count, second.Count);
+ public bool IsReadOnly => true;
+
+ public IEnumerator<(TA first, TB second)> GetEnumerator() => new FastZipIterator(first, second);
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public bool Contains((TA first, TB second) item) => first.Contains(item.first) && second.Contains(item.second);
+
+ public void CopyTo((TA first, TB second)[] array, int arrayIndex)
+ {
+ int count = Count;
+ for (int i = 0; i < count; i++)
+ array[arrayIndex] = (first[i], second[i]);
+ }
+
+ public void Add((TA first, TB second) item) => throw new InvalidOperationException();
+ public void Clear() => throw new InvalidOperationException();
+ public int IndexOf((TA first, TB second) item) => throw new InvalidOperationException();
+ public void Insert(int index, (TA first, TB second) item) => throw new InvalidOperationException();
+ public bool Remove((TA first, TB second) item) => throw new InvalidOperationException();
+ public void RemoveAt(int index) => throw new InvalidOperationException();
+
+
+ internal struct FastZipIterator(IReadOnlyList first, IReadOnlyList second) : IEnumerator<(TA first, TB second)>
+ {
+ private readonly IReadOnlyList first = first;
+ private readonly IReadOnlyList second = second;
+ private readonly int count = Math.Min(first.Count, second.Count);
+ private int pos = -1;
+
+ public readonly (TA first, TB second) Current
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get =>(first[pos], second[pos]);
+ }
+
+ readonly object IEnumerator.Current => Current;
+
+ public readonly void Dispose() { }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool MoveNext()
+ {
+ pos++;
+ return pos < count;
+ }
+
+ public void Reset()
+ {
+ pos = -1;
+ }
+ }
+
+ /*internal struct FastArrayZipIterator(TA[] first, TB[] second) : IEnumerator<(TA first, TB second)>
+ {
+ private readonly TA[] aArr = first;
+ private readonly TB[] bArr = second;
+ private readonly int count = Math.Min(first.Length, second.Length);
+ private nint pos = -1;
+
+ public readonly (TA first, TB second) Current
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => (aArr[pos], bArr[pos]);
+ }
+
+ readonly object IEnumerator.Current => Current;
+
+ public readonly void Dispose() { }
+
+ public bool MoveNext()
+ {
+ if (pos >= count)
+ return false;
+ pos++;
+ return true;
+ }
+
+ public void Reset()
+ {
+ pos = -1;
+ }
+ }*/
+}
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/MultiDict.cs b/QPlayer/Utilities/MultiDict.cs
index 4b35c0d..de3d5af 100644
--- a/QPlayer/Utilities/MultiDict.cs
+++ b/QPlayer/Utilities/MultiDict.cs
@@ -1,6 +1,8 @@
-using System;
+using QPlayer.ViewModels;
+using System;
using System.Collections;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -163,6 +165,13 @@ public void Add(TValue item)
{
if (values != null)
{
+ if (values.Contains(item))
+ {
+#if DEBUG
+ MainViewModel.Log($"Tried adding an item which was already in the multidict! {item}", MainViewModel.LogLevel.Warning);
+#endif
+ return;
+ }
values.Add(item);
return;
}
@@ -172,11 +181,15 @@ public void Add(TValue item)
first = item;
return;
}
- else
+ else if (first != item)
{
values = [first, item];
first = default!;
}
+#if DEBUG
+ else
+ MainViewModel.Log($"Tried adding an item which was already in the multidict! {item}", MainViewModel.LogLevel.Warning);
+#endif
}
public void Clear()
diff --git a/QPlayer/Utilities/ObservableSet.cs b/QPlayer/Utilities/ObservableSet.cs
index d2a028b..3665751 100644
--- a/QPlayer/Utilities/ObservableSet.cs
+++ b/QPlayer/Utilities/ObservableSet.cs
@@ -253,12 +253,26 @@ public bool Remove(T item)
if (!hashSet.Remove(item))
return false;
+ if (hashSet.Count == 1)
+ DemoteHashSetItem();
Success:
OnCollectionChanged(NotifyCollectionChangedAction.Remove, item);
return true;
}
+ private void DemoteHashSetItem()
+ {
+ var ie = hashSet.GetEnumerator();
+ if (ie.MoveNext())
+ {
+ var item = ie.Current;
+ firstItem = item;
+ hashSet.Clear();
+ }
+ ie.Dispose();
+ }
+
[Obsolete] public void ExceptWith(IEnumerable other) => throw new NotImplementedException();
[Obsolete] public void IntersectWith(IEnumerable other) => throw new NotImplementedException();
[Obsolete] public void SymmetricExceptWith(IEnumerable other) => throw new NotImplementedException();
diff --git a/QPlayer/Utilities/OneEnumerable.cs b/QPlayer/Utilities/OneEnumerable.cs
new file mode 100644
index 0000000..c9c4e8a
--- /dev/null
+++ b/QPlayer/Utilities/OneEnumerable.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+
+namespace QPlayer.Utilities;
+
+///
+/// A simple iterator that just wraps a single element.
+///
+///
+[DebuggerDisplay("[{Value}]")]
+public readonly struct OneEnumerable(T value) : IEnumerable, IEnumerable, IList, IList, IReadOnlyList, ICollection, ICollection
+{
+ private readonly T value = value;
+ public readonly T Value => value;
+
+ public T this[int index] { get => index == 0 ? value : throw new IndexOutOfRangeException(); set => throw new NotSupportedException(); }
+ object? IList.this[int index] { get => index == 0 ? value : throw new IndexOutOfRangeException(); set => throw new NotSupportedException(); }
+
+ public int Count => 1;
+ public bool IsReadOnly => true;
+ public bool IsFixedSize => true;
+ public bool IsSynchronized => true;
+ public object SyncRoot => this;
+
+ public IEnumerator GetEnumerator() => new OneIterator(value);
+ IEnumerator IEnumerable.GetEnumerator() => new OneIterator(value);
+
+ public bool Contains(T item) => EqualityComparer.Default.Equals(item, value);
+
+ public bool Contains(object? value) => value?.Equals(this.value) ?? false;
+
+ public void CopyTo(T[] array, int arrayIndex)
+ {
+ if (arrayIndex < 0 || arrayIndex >= array.Length)
+ throw new ArgumentOutOfRangeException(nameof(arrayIndex));
+
+ array[arrayIndex] = value;
+ }
+
+ public void CopyTo(Array array, int index)
+ {
+ if (index < 0 || index >= array.Length)
+ throw new ArgumentOutOfRangeException(nameof(index));
+
+ array.SetValue(value, index);
+ }
+
+ public int IndexOf(T item) => EqualityComparer.Default.Equals(item, value) ? 0 : -1;
+ public int IndexOf(object? value) => (value?.Equals(this.value) ?? false) ? 0 : -1;
+
+ public void Add(T item) => throw new NotSupportedException();
+ public int Add(object? value) => throw new NotSupportedException();
+ public void Clear() => throw new NotSupportedException();
+ public void Insert(int index, T item) => throw new NotSupportedException();
+ public void Insert(int index, object? value) => throw new NotSupportedException();
+ public bool Remove(T item) => throw new NotSupportedException();
+ public void Remove(object? value) => throw new NotSupportedException();
+ public void RemoveAt(int index) => throw new NotSupportedException();
+
+ internal struct OneIterator(T value) : IEnumerator
+ {
+ private readonly T value = value;
+ private bool done = false;
+
+ public readonly T Current => value;
+ readonly object IEnumerator.Current => value!;
+
+ public readonly void Dispose() { }
+ public bool MoveNext()
+ {
+ bool more = !done;
+ done = true;
+ return more;
+ }
+
+ public void Reset() => done = false;
+ }
+}
diff --git a/QPlayer/Utilities/TemporaryList.cs b/QPlayer/Utilities/TemporaryList.cs
index d11349d..7a114ee 100644
--- a/QPlayer/Utilities/TemporaryList.cs
+++ b/QPlayer/Utilities/TemporaryList.cs
@@ -7,6 +7,7 @@
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using System.Text;
namespace QPlayer.Utilities;
@@ -18,7 +19,7 @@ namespace QPlayer.Utilities;
#if NET8_0_OR_GREATER
[CollectionBuilder(typeof(TemporaryListBuilder), nameof(TemporaryListBuilder.Create))]
#endif
-public struct TemporaryList : IList, IDisposable
+public struct TemporaryList : ITempList
{
private ArrayPool? arrayPool;
private T[]? items;
@@ -38,8 +39,27 @@ public readonly T this[int index]
items![index] = value;
}
}
+ readonly object? IList.this[int index]
+ {
+ get
+ {
+ BoundsCheck(index);
+ return items![index];
+ }
+ set
+ {
+ if (value is T item)
+ {
+ BoundsCheck(index);
+ items![index] = item;
+ }
+ }
+ }
public readonly int Count => count;
- public readonly bool IsReadOnly => true;
+ public readonly bool IsReadOnly => false;
+ public readonly bool IsFixedSize => false;
+ public readonly bool IsSynchronized => false;
+ public readonly object SyncRoot => throw new NotImplementedException();
#if NETSTANDARD
#pragma warning disable CS8618 // Non-nulllable field must contain a non-null value when exiting the constructor.
@@ -55,21 +75,23 @@ public TemporaryList(int capacity = 0, ArrayPool? arrayPool = null)
public TemporaryList(ReadOnlySpan items) : this(items.Length, null)
{
items.CopyTo(this.items);
+ count = items.Length;
}
- 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);
+ count = 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 +100,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 +117,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
@@ -126,6 +148,18 @@ public void EnsureCapacity(int capacity)
#endif
}
+ ///
+ /// Sets the number of elements in this list. If the count is increased, new elements will be added at the end
+ /// of the list, these elements may be uninitialised.
+ ///
+ ///
+ public void SetCount(int newCount)
+ {
+ EnsureCapacity(newCount);
+ count = newCount;
+ version++;
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private readonly void BoundsCheck(int index)
{
@@ -161,11 +195,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;
@@ -280,7 +318,128 @@ public readonly T[] ToArray()
return res;
}
+ ///
+ /// Replaces the contents of this list using the given filter enumerable. The filtering is done in place, hence the filter function
+ /// must not reference items which have already been filtered.
+ ///
+ /// An enumerable such as the result of myTempList.Where(x => x == 1)
+ public void Replace(IEnumerable newValues)
+ {
+ int i = 0;
+ foreach (var srcPos in newValues)
+ this[i++] = srcPos;
+ count = i;
+ version++;
+ }
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public int Add(object? value)
+ {
+ if (value is T item)
+ {
+ Add(item);
+ return count - 1;
+ }
+
+ return -1;
+ }
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public readonly bool Contains(object? value) => value is T item && Contains(item);
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public readonly int IndexOf(object? value) => value is T item ? IndexOf(item) : -1;
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public void Insert(int index, object? value)
+ {
+ if (value is T item)
+ Insert(index, item);
+ }
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public void Remove(object? value)
+ {
+ if (value is T item)
+ Remove(item);
+ }
+
+ //[Obsolete("Prefer using the typed variant of this method instead.")]
+ public readonly void CopyTo(Array array, int index)
+ {
+ if (items != null)
+ Array.Copy(items, 0, array, index, count);
+ }
+
+ public readonly ref struct TempListZipEnumerable(ref readonly TemporaryList first, ref readonly TemporaryList second)
+ {
+ private readonly FastZipIterator iterator = new(in first, in second);
+ public FastZipIterator GetEnumerator() => iterator;
+
+ public ref struct FastZipIterator : IEnumerator<(T first, TOther second)>
+ {
+#if NET10_0_OR_GREATER
+ private readonly ref T aRef;
+ private readonly ref TOther bRef;
+#else
+ private readonly T[]? aArr;
+ private readonly TOther[]? bArr;
+#endif
+ private readonly int count;
+ private nint pos;
+
+ public FastZipIterator(ref readonly TemporaryList first, ref readonly TemporaryList second)
+ {
+ count = Math.Min(first.count, second.count);
+ pos = -1;
+#if NET10_0_OR_GREATER
+ if (count > 0)
+ {
+ aRef = ref MemoryMarshal.GetArrayDataReference(first.items!);
+ bRef = ref MemoryMarshal.GetArrayDataReference(second.items!);
+ }
+ else
+ {
+ aRef = ref Unsafe.NullRef();
+ bRef = ref Unsafe.NullRef();
+ }
+#else
+ aArr = first.items;
+ bArr = second.items;
+#endif
+ }
+
+ public readonly (T first, TOther second) Current
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+#if NET10_0_OR_GREATER
+ get => (Unsafe.Add(ref aRef, pos), Unsafe.Add(ref bRef, pos));
+#else
+ get => (aArr![pos], bArr![pos]);
+#endif
+ }
+
+ readonly object IEnumerator.Current => Current;
+
+ public readonly void Dispose() { }
+
+ public bool MoveNext()
+ {
+ if (pos >= count)
+ return false;
+ pos++;
+ return true;
+ }
+
+ public void Reset()
+ {
+ pos = -1;
+ }
+ }
+ }
}
///
@@ -294,3 +453,57 @@ public static class TemporaryListBuilder
///
public static TemporaryList Create(ReadOnlySpan values) => new(values);
}
+
+///
+/// A common interface for temporary lists
+///
+///
+public interface ITempList : IList, IReadOnlyList, IList, IDisposable
+{
+
+}
+
+///
+/// An interface that wraps the common list interfaces: , ,
+///
+///
+public interface IGeneralList : IList, IReadOnlyList, IList
+{
+
+}
+
+///
+/// A struct that wraps a list as a .
+///
+///
+///
+public readonly struct TempListWrapper(IGeneralList src) : ITempList, IGeneralList
+{
+ private readonly IGeneralList src = src;
+
+ public void Dispose() { }
+
+ public readonly T this[int index] { get => ((IList)src)[index]; set => ((IList)src)[index] = value; }
+ readonly object? IList.this[int index] { get => ((IList)src)[index]; set => ((IList)src)[index] = value; }
+ public readonly int Count => ((ICollection)src).Count;
+ public readonly bool IsReadOnly => ((ICollection)src).IsReadOnly;
+ public readonly bool IsFixedSize => src.IsFixedSize;
+ public readonly bool IsSynchronized => src.IsSynchronized;
+ public readonly object SyncRoot => src.SyncRoot;
+ public readonly void Add(T item) => src.Add(item);
+ public readonly int Add(object? value) => src.Add(value);
+ public readonly void Clear() => ((ICollection)src).Clear();
+ public readonly bool Contains(T item) => src.Contains(item);
+ public readonly bool Contains(object? value) => src.Contains(value);
+ public readonly void CopyTo(T[] array, int arrayIndex) => src.CopyTo(array, arrayIndex);
+ public readonly void CopyTo(Array array, int index) => src.CopyTo(array, index);
+ public readonly IEnumerator GetEnumerator() => src.GetEnumerator();
+ public readonly int IndexOf(T item) => src.IndexOf(item);
+ public readonly int IndexOf(object? value) => src.IndexOf(value);
+ public readonly void Insert(int index, T item) => src.Insert(index, item);
+ public readonly void Insert(int index, object? value) => src.Insert(index, value);
+ public readonly bool Remove(T item) => src.Remove(item);
+ public readonly void Remove(object? value) => src.Remove(value);
+ public readonly void RemoveAt(int index) => ((IList)src).RemoveAt(index);
+ readonly IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)src).GetEnumerator();
+}
diff --git a/QPlayer/Utilities/Throttle.cs b/QPlayer/Utilities/Throttle.cs
new file mode 100644
index 0000000..46cec1c
--- /dev/null
+++ b/QPlayer/Utilities/Throttle.cs
@@ -0,0 +1,95 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading;
+using System.Windows.Threading;
+
+namespace QPlayer.Utilities;
+
+///
+/// A throttle object allows an action to be throttled to a maximum rate using the dispatcher.
+///
+public class Throttle
+{
+ private readonly Action onChanged;
+ private readonly Dispatcher dispatcher;
+ private readonly DispatcherTimer timer;
+ private int requestCount;
+
+ ///
+ /// Constructs a new to run with the given minimum between invocations to the action.
+ ///
+ /// The minimum amount of time to wait between invocations of the action.
+ /// The action to invoke on the thread when is called.
+ ///
+ public Throttle(TimeSpan timeout, Action onChanged)
+ {
+ requestCount = 0;
+ this.onChanged = onChanged;
+ dispatcher = Dispatcher.FromThread(Thread.CurrentThread) ?? throw new Exception("Must be called from a thread with an active dispatcher");
+
+ timer = new(DispatcherPriority.Normal, dispatcher);
+ timer.Interval = timeout;
+ timer.Tick += Timer_Tick;
+ }
+
+ private void Timer_Tick(object? sender, EventArgs e)
+ {
+ timer.Stop();
+ if (requestCount <= 0)
+ return;
+
+ requestCount = 0;
+ onChanged();
+ }
+
+ ///
+ /// Tries to invoke this defined action. If this method has been called before the timeout between
+ /// the last call has elapsed, then a single call to the action will be scheduled for after the timeout.
+ ///
+ public void Invoke()
+ {
+ requestCount++;
+ if (!timer.IsEnabled)
+ {
+ // Allow the callback to occur immeadiately if we're not already waiting for the timer.
+ requestCount--;
+ onChanged();
+ timer.Start();
+ }
+ }
+}
+
+///
+/// A throttle object installs an event handler in the target and allows a
+/// callback to be invoked when the targetted property is changed while limiting the rate at which the
+/// callback can occur.
+///
+public class PropThrottle : Throttle
+{
+ private readonly ObservableObject target;
+ private readonly string prop;
+
+ public PropThrottle(ObservableObject target, string prop, TimeSpan timeout, Action onChanged) : base(timeout, onChanged)
+ {
+ this.target = target;
+ this.prop = prop;
+
+ target.PropertyChanged += Target_PropertyChanged;
+ }
+
+ public void Dispose()
+ {
+ target.PropertyChanged -= Target_PropertyChanged;
+ }
+
+ private void Target_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != prop)
+ return;
+
+ Invoke();
+ }
+}
+
diff --git a/QPlayer/Utilities/UndoableObservableCollection.cs b/QPlayer/Utilities/UndoableObservableCollection.cs
index ecc1913..cccc879 100644
--- a/QPlayer/Utilities/UndoableObservableCollection.cs
+++ b/QPlayer/Utilities/UndoableObservableCollection.cs
@@ -6,15 +6,300 @@
using System.Collections.Specialized;
using System.ComponentModel;
using System.Reflection;
+using System.Runtime.InteropServices;
using System.Text;
namespace QPlayer.Utilities;
///
-/// An array-backed list which implements and registers changes with the .
+/// An array-backed list of which implements and registers changes with the .
+///
+/// The type of item stored in this list. Must implement .
+/// The type of items stored in the bound list. ()
+public partial class UndoableObservableCollection : BindableViewModel>, IList, INotifyCollectionChanged, INotifyPropertyChanged
+ where TModel : class, new()
+ where TViewModel : BindableViewModel, new()
+{
+ private readonly List list;
+ private static readonly PropertyChangedEventArgs _countChangedEventArgs = new(nameof(Count));
+ private static readonly PropertyChangedEventArgs _indexerChangedEventArgs = new("Item[]");
+ private static readonly NotifyCollectionChangedEventArgs _collectionResetEventArgs = new(NotifyCollectionChangedAction.Reset);
+
+ public int Count => list.Count;
+ public bool IsReadOnly => false;
+
+ public TViewModel this[int index]
+ {
+ get => list[index];
+ set => SetItem(value, index);
+ }
+
+ public UndoableObservableCollection()
+ {
+ list = [];
+ }
+
+ public UndoableObservableCollection(int capacity)
+ {
+ list = new(capacity);
+ }
+
+ /*public UndoableObservableCollection(IEnumerable enumerable)
+ {
+ list = new(enumerable);
+ }*/
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+ private void OnCollectionChanged() => CollectionChanged?.Invoke(this, _collectionResetEventArgs);
+ private void OnCollectionChanged(NotifyCollectionChangedEventArgs args) => CollectionChanged?.Invoke(this, args);
+ private void OnItemChanged(NotifyCollectionChangedAction action, TViewModel changed, int index) => CollectionChanged?.Invoke(this, new(action, changed, index));
+ private void OnItemChanged(NotifyCollectionChangedAction action, IEnumerable changed, int index) => CollectionChanged?.Invoke(this, new(action, changed, index));
+ private void OnItemChanged(NotifyCollectionChangedAction action, TViewModel oldObj, TViewModel newObj, int index) => CollectionChanged?.Invoke(this, new(action, newObj, oldObj, index));
+ private void OnItemMoved(TViewModel obj, int oldInd, int newInd) => CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Move, obj, newInd, oldInd));
+
+ private void SetItem(TViewModel upd, int ind)
+ {
+ (var old, list[ind]) = (list[ind], upd);
+
+ old.Bind(null);
+ if (BoundModel != null)
+ {
+ upd.Bind(BoundModel[ind]);
+ upd.SyncToModel();
+ }
+
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Replace, old, upd, ind);
+ UndoManager.RegisterAction($"Changed {upd}", () => SetItem(old, ind), () => SetItem(upd, ind));
+ }
+
+ public void Move(int fromIndex, int toIndex)
+ {
+ var removedItem = list[fromIndex];
+
+ list.RemoveAt(fromIndex);
+ list.Insert(toIndex, removedItem);
+
+ BoundModel?.RemoveAt(fromIndex);
+ BoundModel?.Insert(toIndex, removedItem.BoundModel!);
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemMoved(removedItem, fromIndex, toIndex);
+
+ UndoManager.RegisterAction($"Moved {removedItem}", () => Move(toIndex, fromIndex), () => Move(fromIndex, toIndex));
+ }
+
+ public void Insert(int index, TViewModel item)
+ {
+ list.Insert(index, item);
+
+ if (BoundModel != null)
+ {
+ var vm = item;
+ if (vm.BoundModel == null)
+ {
+ vm.Bind(new());
+ vm.SyncToModel();
+ }
+ BoundModel.Insert(index, vm.BoundModel!);
+ }
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Add, item, index);
+
+ UndoManager.RegisterAction($"Added {item}", () => RemoveAt(index), () => Insert(index, item));
+ }
+
+ public void RemoveAt(int index)
+ {
+ var item = list[index];
+ list.RemoveAt(index);
+
+ BoundModel?.RemoveAt(index);
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Remove, item, index);
+
+ UndoManager.RegisterAction($"Removed {item}", () => Insert(index, item), () => RemoveAt(index));
+ }
+
+ public bool Remove(TViewModel item)
+ {
+ int index = list.IndexOf(item);
+ if (index == -1)
+ return false;
+
+ list.RemoveAt(index);
+
+ BoundModel?.RemoveAt(index);
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Remove, item, index);
+
+ UndoManager.RegisterAction($"Removed {item}", () => Insert(index, item), () => RemoveAt(index));
+ return true;
+ }
+
+ public void RemoveLast(int count)
+ {
+ int index = list.Count - count;
+ var removed = list.Slice(index, count);
+ list.RemoveRange(index, count);
+
+ BoundModel?.RemoveRange(index, count);
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Remove, removed, index);
+
+ UndoManager.RegisterAction($"Removed {count} items", () => AddRange(removed), () => RemoveLast(count));
+ }
+
+ public void Add(TViewModel item)
+ {
+ int index = list.Count;
+ list.Add(item);
+
+ if (BoundModel != null)
+ {
+ var vm = item;
+ if (vm.BoundModel == null)
+ {
+ vm.Bind(new());
+ vm.SyncToModel();
+ }
+ BoundModel.Add(vm.BoundModel!);
+ }
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Add, item, index);
+
+ UndoManager.RegisterAction($"Added {item}", () => RemoveAt(index), () => Add(item));
+ }
+
+ public void AddRange(IEnumerable items)
+ {
+ int index = list.Count;
+ list.AddRange(items);
+ int added = list.Count - index;
+
+ if (BoundModel != null)
+ {
+ BoundModel.Capacity = list.Count;
+ for (int i = index; i < list.Count; i++)
+ {
+ var vm = list[i];
+ if (vm.BoundModel == null)
+ {
+ vm.Bind(new());
+ vm.SyncToModel();
+ }
+ BoundModel.Add(vm.BoundModel!);
+ }
+ }
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnItemChanged(NotifyCollectionChangedAction.Add, items, index);
+
+ UndoManager.RegisterAction($"Added {added} items", () => RemoveLast(added), () => AddRange(items));
+ }
+
+ public void Clear()
+ {
+ var oldItems = list.ToArray();
+ list.Clear();
+
+ BoundModel?.Clear();
+
+ OnPropertyChanged(_countChangedEventArgs);
+ OnPropertyChanged(_indexerChangedEventArgs);
+ OnCollectionChanged(_collectionResetEventArgs);
+
+ UndoManager.RegisterAction($"Cleared collection", () => AddRange(oldItems), () => Clear());
+ }
+
+ public int IndexOf(TViewModel item) => list.IndexOf(item);
+ public bool Contains(TViewModel item) => list.Contains(item);
+ public void CopyTo(TViewModel[] array, int arrayIndex = 0) => list.CopyTo(array, arrayIndex);
+
+ public IEnumerator GetEnumerator() => list.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ #region Model Sync
+ ///
+ /// Resynchronises all the cues in this to the bound model
+ /// (set with ).
+ ///
+ public override void SyncToModel()
+ {
+ if (boundModel == null)
+ return;
+ base.SyncToModel();
+
+ CollectionsMarshal.SetCount(boundModel, list.Count);
+ var dst = CollectionsMarshal.AsSpan(boundModel);
+ for (int i = 0; i < list.Count; i++)
+ {
+ var item = list[i];
+ var model = dst[i];
+ if (model == null)
+ dst[i] = model = new();
+
+ item.Bind(model);
+ item.SyncToModel();
+ }
+ }
+
+ ///
+ /// Syncronises the contents of this CueList with the cue models in the bound model
+ /// (set via ).
+ ///
+ public override void SyncFromModel()
+ {
+ base.SyncFromModel();
+
+ if (boundModel == null)
+ return;
+
+ CollectionsMarshal.SetCount(list, boundModel.Count);
+ var dst = CollectionsMarshal.AsSpan(list);
+ for (int i = 0; i < boundModel.Count; i++)
+ {
+ TModel model = boundModel[i];
+ TViewModel item = dst[i];
+ if (item == null)
+ dst[i] = item = new();
+ if (model == null)
+ {
+ // Create a new default instance using the values in this view model.
+ boundModel[i] = model = new();
+ item.Bind(model);
+ item.SyncToModel();
+ }
+ else
+ {
+ item.Bind(model);
+ item.SyncFromModel();
+ }
+ }
+ OnCollectionChanged();
+ }
+ #endregion
+}
+
+///
+/// An array-backed list of values which implements and registers changes with the .
///
///
-public partial class UndoableObservableCollection : IList, INotifyCollectionChanged, INotifyPropertyChanged
+public partial class UndoableObservableCollection : BindableViewModel>, IList, INotifyCollectionChanged, INotifyPropertyChanged
+ where T : struct
{
private readonly List list;
private static readonly PropertyChangedEventArgs _countChangedEventArgs = new(nameof(Count));
@@ -40,16 +325,14 @@ public UndoableObservableCollection(int capacity)
list = new(capacity);
}
- public UndoableObservableCollection(IEnumerable enumerable)
+ /*public UndoableObservableCollection(IEnumerable enumerable)
{
list = new(enumerable);
- }
+ }*/
public event NotifyCollectionChangedEventHandler? CollectionChanged;
- public event PropertyChangedEventHandler? PropertyChanged;
- private void OnPropertyChanged(string? propertyName) => PropertyChanged?.Invoke(this, new(propertyName));
- private void OnPropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args);
+ private void OnCollectionChanged() => CollectionChanged?.Invoke(this, _collectionResetEventArgs);
private void OnCollectionChanged(NotifyCollectionChangedEventArgs args) => CollectionChanged?.Invoke(this, args);
private void OnItemChanged(NotifyCollectionChangedAction action, T changed, int index) => CollectionChanged?.Invoke(this, new(action, changed, index));
private void OnItemChanged(NotifyCollectionChangedAction action, IEnumerable changed, int index) => CollectionChanged?.Invoke(this, new(action, changed, index));
@@ -62,6 +345,7 @@ private void SetItem(T upd, int ind)
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Replace, old, upd, ind);
list[ind] = upd;
+ BoundModel?[ind] = upd;
UndoManager.RegisterAction($"Changed {upd}", () => SetItem(old, ind), () => SetItem(upd, ind));
}
@@ -72,6 +356,9 @@ public void Move(int fromIndex, int toIndex)
list.RemoveAt(fromIndex);
list.Insert(toIndex, removedItem);
+ BoundModel?.RemoveAt(fromIndex);
+ BoundModel?.Insert(toIndex, removedItem);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemMoved(removedItem, fromIndex, toIndex);
@@ -83,6 +370,8 @@ public void Insert(int index, T item)
{
list.Insert(index, item);
+ BoundModel?.Insert(index, item);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Add, item, index);
@@ -95,6 +384,8 @@ public void RemoveAt(int index)
var item = list[index];
list.RemoveAt(index);
+ BoundModel?.RemoveAt(index);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Remove, item, index);
@@ -110,6 +401,8 @@ public bool Remove(T item)
list.RemoveAt(index);
+ BoundModel?.RemoveAt(index);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Remove, item, index);
@@ -124,6 +417,8 @@ public void RemoveLast(int count)
var removed = list.Slice(index, count);
list.RemoveRange(index, count);
+ BoundModel?.RemoveRange(index, count);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Remove, removed, index);
@@ -136,6 +431,8 @@ public void Add(T item)
int index = list.Count;
list.Add(item);
+ BoundModel?.Add(item);
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Add, item, index);
@@ -149,6 +446,8 @@ public void AddRange(IEnumerable items)
list.AddRange(items);
int added = list.Count - index;
+ BoundModel?.AddRange(list.AsSpan(index, added));
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnItemChanged(NotifyCollectionChangedAction.Add, items, index);
@@ -161,6 +460,8 @@ public void Clear()
var oldItems = list.ToArray();
list.Clear();
+ BoundModel?.Clear();
+
OnPropertyChanged(_countChangedEventArgs);
OnPropertyChanged(_indexerChangedEventArgs);
OnCollectionChanged(_collectionResetEventArgs);
@@ -174,4 +475,39 @@ public void Clear()
public IEnumerator GetEnumerator() => list.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ #region Model Sync
+ ///
+ /// Resynchronises all the cues in this to the bound model
+ /// (set with ).
+ ///
+ public override void SyncToModel()
+ {
+ if (boundModel == null)
+ return;
+ base.SyncToModel();
+
+ CollectionsMarshal.SetCount(boundModel, list.Count);
+ var dst = CollectionsMarshal.AsSpan(boundModel);
+ list.CopyTo(dst);
+ }
+
+ ///
+ /// Syncronises the contents of this CueList with the cue models in the bound model
+ /// (set via ).
+ ///
+ public override void SyncFromModel()
+ {
+ base.SyncFromModel();
+
+ if (boundModel == null)
+ return;
+
+ CollectionsMarshal.SetCount(list, boundModel.Count);
+ var dst = CollectionsMarshal.AsSpan(list);
+ boundModel.CopyTo(dst);
+
+ OnCollectionChanged();
+ }
+ #endregion
}
diff --git a/QPlayer/Utilities/ValueConverters.cs b/QPlayer/Utilities/ValueConverters.cs
index c92e620..4068289 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;
@@ -129,6 +130,22 @@ public object ConvertBack(object value, Type targetType, object parameter, Syste
}
}
+[ValueConversion(typeof(float), typeof(double))]
+public class MultiplyByFConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
+ {
+ var fmtNum = CultureInfo.InvariantCulture.NumberFormat;
+
+ return (float)value * double.Parse((string)parameter, fmtNum);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
+ {
+ return DependencyProperty.UnsetValue;
+ }
+}
+
[ValueConversion(typeof(TriggerMode), typeof(int))]
public class TriggerModeConverter : IValueConverter
{
@@ -157,6 +174,20 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
}
}
+[ValueConversion(typeof(GroupTriggerMode), typeof(int))]
+public class GroupTriggerModeConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return (int)(GroupTriggerMode)value;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return (GroupTriggerMode)(int)value;
+ }
+}
+
[ValueConversion(typeof(bool), typeof(Visibility))]
public class VisibilityConverter : IValueConverter
{
diff --git a/QPlayer/ViewModels/BindableViewModel.cs b/QPlayer/ViewModels/BindableViewModel.cs
index cc51a27..82c6cf2 100644
--- a/QPlayer/ViewModels/BindableViewModel.cs
+++ b/QPlayer/ViewModels/BindableViewModel.cs
@@ -24,13 +24,18 @@ namespace QPlayer.ViewModels;
/// on the Model.
///
/// The type of the model to bind to.
-public abstract class BindableViewModel : ObservableObject
+public abstract class BindableViewModel : ObservableObject, IBindableViewModel
where Model : class
{
protected Model? boundModel;
internal Model? BoundModel => boundModel;
+ [Obsolete("Prefer the BoundModel property.")]
+ public object? GetBoundModel() => boundModel;
+ [Obsolete("Prefer the typed Bind(Model? model) method.")]
+ public virtual void Bind(object? model) => Bind(model as Model);
+
///
/// Binds this to the specified model instance. Automatically propagates proeprties
/// changes from this object to the model, but not the other way around.
@@ -99,6 +104,50 @@ protected virtual void OnSyncFromModel() { }
protected virtual void OnSyncToModel() { }
}
+/*public interface IMainViewModelConstructor
+{
+ public abstract static IMainViewModelConstructor Create(MainViewModel mainViewModel);
+}*/
+
+///
+/// Base interface for all
+///
+public interface IBindableViewModel
+{
+ ///
+ /// Gets the currently bound model or if one isn't bound.
+ /// See the for a typed version
+ /// of this method.
+ ///
+ ///
+ public abstract object? GetBoundModel();
+ ///
+ /// Binds this to the specified model instance. Automatically propagates proeprties
+ /// changes from this object to the model, but not the other way around.
+ ///
+ /// When using the source generator, this method is automatically implemented so long as the deriving
+ /// class defines at least one reactive property (see ).
+ ///
+ /// The model to bind to, or to unbind.
+ public abstract void Bind(object? model);
+
+ ///
+ /// Copies all bound property values on this instance from the bound model.
+ ///
+ /// When using the source generator, this method is automatically implemented so long as the deriving
+ /// class defines at least one reactive property (see ).
+ ///
+ public abstract void SyncFromModel();
+
+ ///
+ /// Copies all bound property values on this instance to the bound model.
+ ///
+ /// When using the source generator, this method is automatically implemented so long as the deriving
+ /// class defines at least one reactive property (see ).
+ ///
+ public abstract void SyncToModel();
+}
+
public interface ITypedConverter
{
public static abstract TTo Convert(TFrom from);
diff --git a/QPlayer/ViewModels/CueFactory.cs b/QPlayer/ViewModels/CueFactory.cs
index 6056c29..86a36b5 100644
--- a/QPlayer/ViewModels/CueFactory.cs
+++ b/QPlayer/ViewModels/CueFactory.cs
@@ -34,7 +34,7 @@ static CueFactory()
public static Cue? CreateCue(string typeName)
{
if (registeredCueTypes.TryGetValue(typeName, out var registered))
- return Activator.CreateInstance(registered.modelType) as Cue;
+ return Activator.CreateInstance(registered.modelType, true) as Cue;
return null;
}
@@ -48,7 +48,7 @@ static CueFactory()
public static CueViewModel? CreateViewModel(string typeName, MainViewModel mainViewModel)
{
if (registeredCueTypes.TryGetValue(typeName, out var registered))
- return Activator.CreateInstance(registered.viewModelType, mainViewModel) as CueViewModel;
+ return registered.viewModelCtor.Invoke([mainViewModel]) as CueViewModel;
return null;
}
@@ -75,7 +75,7 @@ static CueFactory()
///
/// The view model to create a model for.
/// to bind the to the newly created
- /// model, to only copy it's parameter.
+ /// model, to only copy its parameters.
///
public static Cue? CreateCueForViewModel(CueViewModel vm, bool copy = false)
{
@@ -83,7 +83,7 @@ static CueFactory()
if (vmType.GetCustomAttribute() is not ModelAttribute modelAttr)
return null;
- var cue = Activator.CreateInstance(modelAttr.ModelType) as Cue;
+ var cue = Activator.CreateInstance(modelAttr.ModelType, true) as Cue;
var oldModel = vm.BoundModel;
vm.Bind(cue);
vm.SyncToModel();
@@ -113,7 +113,7 @@ internal static RegisteredCueType[] RegisterAssembly(Assembly assembly)
if (vmType.GetCustomAttribute() is not ModelAttribute modelAttr)
{
- MainViewModel.Log($"failed to register cue type '{vmType.Name}' as it does not specify an associated model type. " +
+ MainViewModel.Log($"Failed to register cue type '{vmType.Name}' as it does not specify an associated model type. " +
$"(See the [Model(...)] attribute for details.)", MainViewModel.LogLevel.Error);
continue;
}
@@ -140,7 +140,14 @@ internal static RegisteredCueType[] RegisterAssembly(Assembly assembly)
var icon = vmType.GetCustomAttribute();
- RegisteredCueType cueDetails = new(modelType.Name, displayName, modelType, vmType, viewType, assembly.FullName ?? string.Empty, icon?.Name, icon?.ResourceDictionary);
+ var vmCtor = vmType.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, [typeof(MainViewModel)]);
+ if (vmCtor == null)
+ {
+ MainViewModel.Log($"Failed to register cue type '{vmType.Name}' as it does not have a constructor with the expected signature.", MainViewModel.LogLevel.Error);
+ continue;
+ }
+
+ RegisteredCueType cueDetails = new(modelType.Name, displayName, modelType, vmType, vmCtor, viewType, assembly.FullName ?? string.Empty, icon?.Name, icon?.ResourceDictionary);
registeredCueTypes.Add(cueDetails.name, cueDetails);
viewModelToCueType.Add(cueDetails.viewModelType, cueDetails);
@@ -151,13 +158,15 @@ internal static RegisteredCueType[] RegisterAssembly(Assembly assembly)
return registered.ToArray();
}
- public readonly struct RegisteredCueType(string name, string displayName, Type modelType, Type viewModelType,
- Type viewType, string assembly, string? iconName, Type? iconResourceDict)
+ public readonly struct RegisteredCueType(string name, string displayName, Type modelType,
+ Type viewModelType, ConstructorInfo viewModelCtor, Type viewType, string assembly,
+ string? iconName, Type? iconResourceDict)
{
public readonly string name = name;
public readonly string displayName = displayName;
public readonly Type modelType = modelType;
public readonly Type viewModelType = viewModelType;
+ public readonly ConstructorInfo viewModelCtor = viewModelCtor;
public readonly Type viewType = viewType;
public readonly string assembly = assembly;
public readonly string? iconName = iconName;
diff --git a/QPlayer/ViewModels/CueList.cs b/QPlayer/ViewModels/CueList.cs
new file mode 100644
index 0000000..a7dd6e7
--- /dev/null
+++ b/QPlayer/ViewModels/CueList.cs
@@ -0,0 +1,1876 @@
+using QPlayer.Models;
+using QPlayer.Utilities;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using static QPlayer.ViewModels.MainViewModel;
+
+namespace QPlayer.ViewModels;
+
+///
+/// The base class for a hierarchical list of cues.
+///
+public class CueList : BindableViewModel>, IReadOnlyList
+{
+ private readonly List rootCueList = [];
+ private readonly HashSet groups = [];
+ private readonly MainViewModel? mainViewModel;
+ private readonly GroupCueViewModel? ownerGroup;
+ ///
+ /// The attached visual cue list.
+ ///
+ private VisualCueList? visualList;
+ ///
+ /// Stores the total number of cues in this list. This number is set by the at the root of the hierarchy.
+ ///
+ private int totalCount = 0;
+ ///
+ /// Is this cue list in the .
+ ///
+ internal bool inMainList = false;
+
+ ///
+ /// This list of cues managed by this cue list, doesn't contain any cues belonging to child groups or parents.
+ ///
+ internal ReadOnlyCollection Cues { get; init; }
+ ///
+ /// The group cue which owns this cue list or if this cue list is not owned by a group (or is the root cue list).
+ ///
+ internal GroupCueViewModel? OwnerGroup => ownerGroup;
+
+ ///
+ /// The number of cues at the root of the cue list (ie: not counting sub-cues)
+ ///
+ public int Count => rootCueList.Count;
+ ///
+ /// The total number of cues in the cue list.
+ ///
+ public int TotalCount => totalCount;
+ ///
+ /// Whether this cue list is empty.
+ ///
+ public bool IsEmpty => rootCueList.Count == 0;
+
+ ///
+ /// Gets the cue list which this cue list is inside of.
+ ///
+ private CueList? ParentList => ownerGroup?.Parent is GroupCueViewModel parentGroup ? parentGroup.Cues : (inMainList ? mainViewModel?.CueList : null);
+
+ ///
+ /// A delegate for changes in the contents of this CueList.
+ ///
+ /// Whether this changed cues in this event were inserted or deleted.
+ /// The list of cues which were changed.
+ /// The list of cue positions of the changed cues.
+ public delegate void CueListChangedDelegate(bool wasInserted, IEnumerable changedCues, IEnumerable? positions);
+ ///
+ /// An event raised whenever the contents of this cue list has changed. (Only changes to direct descendants are raised)
+ ///
+ public event CueListChangedDelegate? CueListChanged;
+
+ ///
+ /// Gets a cue by position.
+ ///
+ ///
+ ///
+ public CueViewModel this[CuePosition pos]
+ {
+ get => pos.group != null ? pos.group.Cues[pos.index] : Cues[pos.index];
+ }
+
+ ///
+ /// Gets a root cue by index.
+ ///
+ ///
+ ///
+ public CueViewModel this[int index]
+ {
+ get => rootCueList[index];
+ }
+
+ public CueList(MainViewModel? mainViewModel, GroupCueViewModel? ownerGroup = null)
+ {
+ Cues = rootCueList.AsReadOnly();
+ this.mainViewModel = mainViewModel;
+ this.ownerGroup = ownerGroup;
+ if (ownerGroup == null)
+ visualList = mainViewModel?.Cues;
+ //parentList = ownerGroup?.Parent is GroupCueViewModel parentGroup ? parentGroup.Cues : visualList?.CueList;
+ }
+
+ #region Public API
+ public CuePosition CreateCuePosition(int index) => new(index, OwnerGroup);
+
+ ///
+ /// Finds the of the specified cue in the cue list.
+ ///
+ ///
+ ///
+ /// Used internally.
+ /// if the cue was found.
+ public bool Find(CueViewModel cue, out CuePosition position, GroupCueViewModel? defaultGroup = null)
+ {
+ // TODO: There isn't really a good way to make this more efficient, which unfortunately affects the
+ // performance of many methods that depend on this one. If testing indicates that this is a performance
+ // bottleneck, then maybe we could consider building an index cache. This cache would probably only be
+ // generated on save and would become invalid as soon as this cue list is mutated. I can't think of a
+ // way to keep an index cache up-to-date for cheap (even if it's just to get an approximately correct
+ // index).
+ int ind = rootCueList.IndexOf(cue);
+ if (ind == -1)
+ {
+ foreach (var group in groups)
+ {
+ var res = group.Cues.Find(cue, out position, group);
+ if (res)
+ return true;
+ }
+ position = default;
+ return false;
+ }
+
+ position = new(ind, defaultGroup);
+ return true;
+ }
+
+ ///
+ /// Shuffles the contents of this subgroup by deleting and re-inserting them.
+ ///
+ internal void Shuffle()
+ {
+ if (ownerGroup == null)
+ return;
+ var cues = Delete(new CuePositionRangeEnumerable(ownerGroup, 0, Count), false);
+ Random.Shared.Shuffle(cues);
+ Insert(CreateCuePosition(0), cues);
+ }
+
+ ///
+ /// Inserts a single cue into the cue list or sublist.
+ ///
+ /// The position at which to insert the cue.
+ /// The cue to insert.
+ public void Insert(CuePosition pos, CueViewModel cue)
+ {
+ InsertSingleInternal(pos, cue);
+ NotifyVisualInsert(new OneEnumerable(cue), new OneEnumerable(pos));
+ }
+
+ ///
+ /// Inserts a collection of cues at the given position in the cue list or sublist.
+ ///
+ /// The position at which to insert the first cue in the collection.
+ /// The collection of cues to insert.
+ public void Insert(CuePosition pos, IEnumerable cues)
+ {
+ var list = GetList(pos);
+ int ind = Math.Max(0, pos.index);
+ int count = 0;
+ foreach (var cue in cues)
+ {
+ ind = Math.Min(ind, list.Count);
+ if (!list.InsertInternal(ind, cue))
+ Log($"Failed to insert cue {cue.FullQID} at position {pos}!", LogLevel.Warning);
+ count++;
+ ind++;
+ }
+
+ NotifyVisualInsert(cues, new CuePositionRangeEnumerable(pos.group, ind - count, count));
+ }
+
+ ///
+ /// Inserts a collection of cues at the given positions in the cues list or sublist.
+ ///
+ ///
+ /// Note, that the cues are inserted in order hence care needs to be taken specifying the cue
+ /// positions as these may need to shift as cues are inserted.
+ ///
+ /// The positions at which to insert the cues.
+ /// The cues to insert into the list.
+ public void Insert(IEnumerable positions, IEnumerable cues)
+ {
+ foreach (var (pos, cue) in positions.FastZip(cues))
+ InsertSingleInternal(pos, cue);
+
+ NotifyVisualInsert(cues, positions);
+ }
+
+ private void InsertSingleInternal(CuePosition pos, CueViewModel cue)
+ {
+ var list = GetList(pos);
+ int ind = Math.Clamp(pos.index, 0, list.Count);
+ if (!list.InsertInternal(ind, cue))
+ Log($"Failed to insert cue {cue.FullQID} at position {pos}!", LogLevel.Warning);
+ }
+
+ ///
+ /// Removes a cue from this cue list (or it's children).
+ ///
+ /// The cue instance to remove.
+ /// Whether the cue was deleted.
+ public bool Delete(CueViewModel cue)
+ {
+ if (!Find(cue, out var pos))
+ return false;
+
+ var list = GetList(pos);
+ var res = list.DeleteInternal(pos.index);
+
+ if (res != null)
+ NotifyVisualDelete([res], [pos]);
+
+ return res != null;
+ }
+
+ ///
+ /// Removes cues from this cue list (or it's children).
+ ///
+ /// The cue instances to remove.
+ /// The cues which were deleted.
+ public CueViewModel[] Delete(IEnumerable cues, bool collapseChildren = false)
+ {
+ using var positions = GetPositionsSorted(cues, collapseChildren);
+ var results = Delete(positions, false, false);
+
+ return results;
+ }
+
+ ///
+ /// Removes a cue from this cue list (or it's children).
+ ///
+ /// The cue position to remove.
+ /// The deleted cue, or if the was invalid.
+ public CueViewModel? Delete(CuePosition pos) => Delete(new OneEnumerable(pos), false).FirstOrDefault();
+
+ ///
+ /// Removes cues from this cue list (or it's children).
+ ///
+ /// The cue positions to remove.
+ /// If the enumerable of cues is already in the order defined by
+ /// , specify to skip sorting again.
+ /// When specified, positions which are children of another position in the enumerable are
+ /// skipped. This makes logical sense as deleting a group already implies deleting it's children, this option prevents those
+ /// children from being removed from the deleted group.
+ /// The cues which were deleted.
+ public CueViewModel[] Delete(IEnumerable positions, bool needsSorting = true, bool collapseChildren = false)
+ {
+ using TemporaryList cuesList = default;
+ using TemporaryList removedItemsRev = default; // Add the removed items in reverse
+
+ if (needsSorting)
+ {
+ cuesList.AddRange(positions);
+ SortPositions(cuesList.AsSpan());
+ positions = cuesList;
+ }
+
+ if (collapseChildren)
+ {
+ // Reuse the cuesList temp list as a buffer for CollapseCuesOrdered
+ if (cuesList.Count == 0)
+ cuesList.AddRange(positions);
+
+ cuesList.Replace(CollapseCuePositions(positions));
+
+ positions = cuesList;
+ }
+
+ foreach (var pos in positions.FastReverse())
+ {
+ var list = GetList(pos);
+ if (list.DeleteInternal(pos.index) is not CueViewModel removed)
+ continue;
+
+ removedItemsRev.Add(removed);
+ }
+
+ var removedArr = removedItemsRev.FastReverse().ToArray();
+ NotifyVisualDelete(removedArr, positions);
+
+ return removedArr;
+ }
+
+ ///
+ /// Clears all the cues from this list.
+ ///
+ public void Clear()
+ {
+ visualList?.ResetVisualList();
+ ClearInternal();
+ }
+ #endregion
+
+ #region Internal Insert/Delete
+ ///
+ /// Enumerates a collection of cue positions, skipping any positions which are children of other positions in the collection.
+ ///
+ ///
+ ///
+ internal IEnumerable CollapseCuePositions(IEnumerable positions)
+ {
+ HashSet skip = [];
+ foreach (var pos in positions)
+ if (this[pos] is GroupCueViewModel group)
+ skip.Add(group);
+ foreach (var pos in positions)
+ {
+ // Top-level cues are always returned
+ if (pos.group == null)
+ {
+ yield return pos;
+ continue;
+ }
+
+ var parent = pos.group;
+ GroupCueViewModel? lastParent = null;
+ bool skipCurrent = false;
+ while (parent != null)
+ {
+ // Check if any of this position's parents are in the skip list
+ if (skip.Contains(parent))
+ {
+ skipCurrent = true;
+ // Add the lalst parent to the skip list if it exists to save time next time
+ if (lastParent != null)
+ skip.Add(lastParent);
+ break;
+ }
+ lastParent = parent;
+ parent = parent.Parent as GroupCueViewModel;
+ }
+
+ if (skipCurrent)
+ continue;
+
+ yield return pos;
+ }
+ }
+
+ private CueList GetList(CuePosition pos) => pos.group != null ? pos.group.Cues : this; // TODO: This should assert that the group is part of this cue list's hierarchy
+
+ ///
+ /// Sorts a span of cue positions by their depth-first hierarchical order. This is the same as the visual order of the
+ /// cues, but doesn't depend on them being in the visual list. This method relies on the parents of the positions existing
+ /// in this cue list.
+ ///
+ ///
+ private void SortPositions(Span positions)
+ {
+ if (positions.Length < 2)
+ return;
+
+ if (positions.Length < totalCount / 4)
+ {
+ // Sort by comparing positions
+ // O(n log n) where n is positions, the comparer is also O(n) worst case
+ var comparer = new CuePositionComparer(this);
+ positions.Sort(comparer);
+ }
+ else
+ {
+ // Check for trivially sorted inputs
+ var last = positions[0];
+ bool sorted = true;
+ for (int j = 1; j < positions.Length; j++)
+ {
+ var next = positions[j];
+ if (last.group != next.group || last.index > next.index)
+ {
+ sorted = false;
+ break;
+ }
+ last = next;
+ }
+ if (sorted)
+ return;
+
+ // Sort by enumerating and filtering the whole cue list.
+ // O(m) where m is cue list length
+ var positionsSet = new HashSet(positions.Length);
+ foreach (var pos in positions)
+ positionsSet.Add(pos);
+ int i = 0;
+ foreach (var cand in EnumerateAllPositions())
+ {
+ if (positionsSet.Contains(cand))
+ positions[i++] = cand;
+ if (i == positions.Length)
+ break;
+ }
+ for (; i < positions.Length; i++)
+ positions[i] = CuePosition.Invalid;
+ }
+
+ /*Dictionary groupPositions = [];
+
+ // https://en.wikipedia.org/wiki/Heapsort#Standard_implementation
+ int start = positions.Length / 2;
+ int end = positions.Length;
+ while (end > 1)
+ {
+ // Extract
+ if (start > 0)
+ start--;
+ else
+ {
+ end--;
+ (positions[end], positions[0]) = (positions[0], positions[end]);
+ }
+
+ // Sift down
+ int root = start;
+ int child;
+ while ((child = LeftChild(root)) < end)
+ {
+ if (child + 1 < end && LessThan(positions[child], positions[child + 1]))
+ child++;
+
+ if (LessThan(positions[root], positions[child]))
+ {
+ (positions[root], positions[child]) = (positions[child], positions[root]);
+ }
+ else
+ break;
+ }
+ }
+
+ //static int LeftChild(int i) => (i >> 1) + 1;
+ //int RightChild(int i) => (i >> 1) + 2;
+ //int Parent(int i) => (i - 1) << 1;
+ bool LessThan(CuePosition a, CuePosition b)
+ {
+ // Trivial case
+ if (a.group == b.group)
+ return a.index < b.index;
+
+ int aParentCount = CountParents(a.group);
+ int bParentCount = CountParents(b.group);
+ // Move to the same parent depth
+ while (aParentCount > bParentCount)
+ {
+ a = GetParentPos(a.group!); // a must have at least one parent in this path
+ aParentCount--;
+ }
+ while (bParentCount > aParentCount)
+ {
+ b = GetParentPos(b.group!); // b must have at least one parent in this path
+ bParentCount--;
+ }
+ while (a.group != b.group)
+ {
+ a = GetParentPos(a.group!); // this is safe, since they should reach null (the root) at the same
+ // time, hence the while loop would exit before this is dereferenced
+ b = GetParentPos(b.group!);
+ }
+
+ return a.index < b.index;
+ }
+
+ CuePosition GetParentPos(GroupCueViewModel cue)
+ {
+ if (groupPositions.TryGetValue(cue, out var pos))
+ return pos;
+ if (Find(cue, out pos))
+ {
+ groupPositions.Add(cue, pos);
+ return pos;
+ }
+ return CuePosition.Invalid;
+ }
+
+ static int CountParents(CueViewModel? cue)
+ {
+ int count = 0;
+ while (cue != null)
+ {
+ count++;
+ cue = cue.Parent;
+ }
+ return count;
+ }*/
+ }
+
+ private readonly struct CuePositionComparer(CueList cueList) : IComparer
+ {
+ private readonly Dictionary groupPositions = [];
+
+ public int Compare(CuePosition x, CuePosition y)
+ {
+ // Trivial case
+ if (x.group == y.group)
+ return x.index.CompareTo(y.index);
+
+ int aParentCount = CountParents(x.group);
+ int bParentCount = CountParents(y.group);
+ // Move to the same parent depth
+ while (aParentCount > bParentCount)
+ {
+ x = GetParentPos(x.group!); // a must have at least one parent in this path
+ aParentCount--;
+ }
+ while (bParentCount > aParentCount)
+ {
+ y = GetParentPos(y.group!); // b must have at least one parent in this path
+ bParentCount--;
+ }
+ while (x.group != y.group)
+ {
+ x = GetParentPos(x.group!); // this is safe, since they should reach null (the root) at the same
+ // time, hence the while loop would exit before this is dereferenced
+ y = GetParentPos(y.group!);
+ }
+
+ return x.index.CompareTo(y.index);
+ }
+
+ CuePosition GetParentPos(GroupCueViewModel cue)
+ {
+ if (groupPositions.TryGetValue(cue, out var pos))
+ return pos;
+ if (cueList.Find(cue, out pos))
+ {
+ groupPositions.Add(cue, pos);
+ return pos;
+ }
+ return CuePosition.Invalid;
+ }
+
+ static int CountParents(CueViewModel? cue)
+ {
+ int count = 0;
+ while (cue != null)
+ {
+ count++;
+ cue = cue.Parent;
+ }
+ return count;
+ }
+ }
+
+ private readonly struct CuePositionIndComparer : IComparer
+ {
+ public readonly int Compare(CuePosition x, CuePosition y) => x.index.CompareTo(y.index);
+ }
+
+ internal void AttachVisualList(VisualCueList? list) => visualList = list;
+
+ private void NotifyVisualInsert(IEnumerable cues, IEnumerable positions)
+ {
+ var list = this;
+ while (list != null)
+ {
+ list.visualList?.NotifyVisualInsert(cues, positions);
+
+ list = list.ParentList;
+ if (list?.ownerGroup is GroupCueViewModel group && group.IsCollapsed)
+ break;
+ }
+ }
+
+ private void NotifyVisualDelete(IEnumerable cues, IEnumerable positions)
+ {
+ var list = this;
+ while (list != null)
+ {
+ list.visualList?.NotifyVisualDelete(cues, positions);
+
+ list = list.ParentList;
+ if (list?.ownerGroup is GroupCueViewModel group && group.IsCollapsed)
+ break;
+ }
+ }
+
+ private void IncrementTotalCount(int delta)
+ {
+ if (delta == 0)
+ return;
+
+ totalCount += delta;
+
+ var parent = ParentList;
+ while (parent != null)
+ {
+ parent.totalCount += delta;
+ parent = parent.ParentList;
+ }
+ }
+
+ ///
+ /// Inserts a cue at the given index in the root cue list.
+ ///
+ /// Should only be called by
+ ///
+ ///
+ ///
+ private bool InsertInternal(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!);
+ }
+ int added = 1;
+ if (item is GroupCueViewModel group)
+ {
+ groups.Add(group);
+ added += group.Cues.totalCount;
+ }
+
+ item.Parent = OwnerGroup;
+ IncrementTotalCount(added);
+
+ OnCueListChanged(true, item, CreateCuePosition(index));
+ //if (group != null)
+ // OnCueListChanged(true, group.Cues.EnumerateAll())
+
+ return true;
+ }
+
+ ///
+ /// Inserts a range of ordered cues at the specified index in the root cue list.
+ ///
+ /// Should only be called by
+ ///
+ ///
+ ///
+ private bool InsertInternal(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!));
+
+ int added = 0;
+ int itemsCount = 0;
+ foreach (var item in items)
+ {
+ added++;
+ if (item is GroupCueViewModel group)
+ {
+ groups.Add(group);
+ added += group.Cues.totalCount;
+ }
+ item.Parent = OwnerGroup;
+ itemsCount++;
+ }
+ IncrementTotalCount(added);
+ OnCueListChanged(true, items, Enumerable.Range(index, itemsCount).Select(CreateCuePosition));
+
+ return true;
+ }
+
+ ///
+ /// Removes a cue by index from the root cue list.
+ ///
+ /// Should only be called by
+ ///
+ /// The cue that was removed or if the index was invalid.
+ private CueViewModel? DeleteInternal(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);
+
+ int removed = 1;
+ if (item is GroupCueViewModel group)
+ {
+ groups.Remove(group);
+ removed += group.Cues.totalCount;
+ }
+ IncrementTotalCount(-removed);
+ UndoManager.SuppressRecording();
+ item.Parent = null;
+ UndoManager.UnSuppressRecording();
+ OnCueListChanged(false, item, CreateCuePosition(index));
+
+ return item;
+ }
+
+ ///
+ /// Removes a range of cues by index from the root cue list.
+ ///
+ /// Should only be called by
+ /// The enumerable of indices to remove.
+ /// 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.
+ private CueViewModel[] DeleteInternal(IEnumerable indices, bool isSorted = false)
+ {
+ var list = rootCueList;
+ var model = boundModel;
+
+ using TemporaryList results = [];
+
+ var inds = indices;
+ TemporaryList