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.Tests/CueListTests.cs b/QPlayer.Tests/CueListTests.cs
new file mode 100644
index 0000000..b1a13bb
--- /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(CueList 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 CueList MakeCueList()
+ {
+ CueList 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 (CueList, GroupCueViewModel) MakeGroupCueList()
+ {
+ CueList 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 AbstractCueList.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 AbstractCueList.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 AbstractCueList.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 AbstractCueList.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 CueList.CuePositionComparer();
+ var group = MakeGroupCue();
+
+ var posNullGroup = new AbstractCueList.CuePosition(0, null);
+ var posWithGroup = new AbstractCueList.CuePosition(0, group);
+ var posNullGroupHigherIndex = new AbstractCueList.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..a34c3ac
--- /dev/null
+++ b/QPlayer.Tests/QPlayer.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ enable
+ enable
+ Exe
+ net10.0-windows
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer.sln b/QPlayer.sln
index 9e33f76..94cc52a 100644
--- a/QPlayer.sln
+++ b/QPlayer.sln
@@ -24,6 +24,8 @@ 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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -168,6 +170,26 @@ 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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
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/Resources/Icons/ConvertedIcons.xaml b/QPlayer/Resources/Icons/ConvertedIcons.xaml
index ecfc334..c68ccd0 100644
Binary files a/QPlayer/Resources/Icons/ConvertedIcons.xaml and b/QPlayer/Resources/Icons/ConvertedIcons.xaml differ
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..12b325e 100644
--- a/QPlayer/ThemesV2/Controls.xaml
+++ b/QPlayer/ThemesV2/Controls.xaml
@@ -1869,7 +1869,9 @@
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
+
diff --git a/QPlayer/Views/CueDataControl.xaml.cs b/QPlayer/Views/CueDataControl.xaml.cs
index 7af06a6..cd40490 100644
--- a/QPlayer/Views/CueDataControl.xaml.cs
+++ b/QPlayer/Views/CueDataControl.xaml.cs
@@ -13,6 +13,7 @@
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
+using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
@@ -39,18 +40,41 @@ private DrawingImage? CueIcon_Template
return DefaultCueIcon;
}
}
+ [Reactive("ExpanderVisibility")]
+ private Visibility ExpanderVisibility_Template => (DataContext is GroupCueViewModel) ? Visibility.Visible : Visibility.Collapsed;
+ [Reactive("IsCollapsed")]
+ private bool IsCollapsed_Template
+ {
+ get
+ {
+ if (DataContext is not GroupCueViewModel gc)
+ return false;
+ return gc.IsCollapsed;
+ }
+ set
+ {
+ if (DataContext is GroupCueViewModel gc)
+ gc.IsCollapsed = value;
+ }
+ }
const int DragDeadzone = 10;
private Point startPos;
+ private CueViewModel? vm;
+ private GroupCueViewModel? group;
+ private static CornerRadius defaultCornerRadius;
+ private static DrawingImage? defaultCueIcon;
internal static readonly StringDict cueIcons = [];
internal static DrawingImage? DefaultCueIcon
{
get
{
+ if (defaultCueIcon != null)
+ return defaultCueIcon;
if (App.Current.Resources.Contains("IconPlay"))
- return (DrawingImage)App.Current.Resources["IconPlay"];
+ return defaultCueIcon = (DrawingImage)App.Current.Resources["IconPlay"];
return null;
}
}
@@ -64,11 +88,51 @@ public CueDataControl()
//this.DataContext = this;
}
+ internal void NotifyGroupMarkerChange(int visualIndex)
+ {
+ if (vm == null)
+ return;
+
+ var isLast = vm.MainViewModel.Cues.IsLastInGroup(visualIndex);
+
+ int parents = 0;
+ var parent = vm;
+ while ((parent = parent.Parent) != null)
+ parents++;
+ int groupDepth = parents;
+ if (group != null)
+ groupDepth++;
+
+ if (groupDepth > 0)
+ {
+ GroupMarker.Visibility = Visibility.Visible;
+ GroupMarker.Opacity = groupDepth * 0.25;
+ GroupMarker.BorderThickness = new(groupDepth * 4, 0, 0, isLast ? 1 : 0);
+ if (isLast && groupDepth == 1)
+ GroupMarker.CornerRadius = new(0, 0, 0, defaultCornerRadius.BottomLeft);
+ else
+ GroupMarker.CornerRadius = default;
+ NameTextBox.Margin = new(parents * 12, 0, 0, 0);
+ ColourSwatch.Margin = new(groupDepth * 4, 0, 0, 0);
+ }
+ else
+ {
+ GroupMarker.Visibility = Visibility.Collapsed;
+ NameTextBox.Margin = default;
+ ColourSwatch.Margin = default;
+ }
+ }
+
+ private void SetGroupMarkerBGBinding()
+ {
+ GroupMarker.SetBinding(Border.BackgroundProperty, group != null ? nameof(CueViewModel.ColourBrush) : nameof(CueViewModel.Parent) + "." + nameof(CueViewModel.ColourBrush));
+ }
+
private void Grid_MouseDown(object sender, MouseButtonEventArgs e)
{
startPos = e.GetPosition(this);
- if (DataContext is not CueViewModel vm)
+ if (vm == null)
return;
if (vm.MainViewModel != null)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () => vm.SelectCommand.Execute(null));
@@ -76,25 +140,107 @@ private void Grid_MouseDown(object sender, MouseButtonEventArgs e)
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
+ if (defaultCueIcon == default && Resources["CornerRadiusDummy"] is Border border)
+ defaultCornerRadius = border.CornerRadius;
+
+ Init();
+ }
+
+ private void UserControl_Unloaded(object sender, RoutedEventArgs e)
+ {
+ vm?.PropertyChanged -= OnCuePropertyChanged;
+ }
+
+ private void UserControl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ Init();
+ }
+
+ private void Init()
+ {
+ OnPropertyChanged(nameof(CueIcon));
+ OnPropertyChanged(nameof(ExpanderVisibility));
+ OnPropertyChanged(nameof(IsCollapsed));
+
+ this.vm?.PropertyChanged -= OnCuePropertyChanged;
+
if (DataContext is not CueViewModel vm)
return;
- vm.PropertyChanged += (o, e) =>
+
+ vm.PropertyChanged += OnCuePropertyChanged;
+
+ this.vm = vm;
+ this.group = vm as GroupCueViewModel;
+
+ SetGroupMarkerBGBinding();
+ if ((vm.Parent != null || group != null)
+ && vm.MainViewModel.Cues.FindVisualIndex(vm, out var ind))
+ NotifyGroupMarkerChange(ind);
+ }
+
+ private void OnCuePropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (vm == null)
+ return;
+
+ switch (e.PropertyName)
{
- switch (e.PropertyName)
- {
- case nameof(CueViewModel.IsSelected):
- if (vm.IsSelected)
- {
- // This is a lazy way to check if the last action that selected us was a click or some other kind of Go()
- // If the user clicks on the element we shouldn't risk it moving too much
- if (IsMouseOver)
- BringIntoView();
- else
- BringIntoView(new Rect(new Size(10, 200))); // Leave some padding below us
- }
- break;
- }
- };
+ case nameof(CueViewModel.IsSelected):
+ if (vm.IsSelected)
+ {
+ // This is a lazy way to check if the last action that selected us was a click or some other kind of Go()
+ // If the user clicks on the element we shouldn't risk it moving too much
+ if (IsMouseOver)
+ BringIntoView();
+ else
+ BringIntoView(new Rect(new Size(10, 200))); // Leave some padding below us
+ }
+ ComputeSelOutline();
+ break;
+ case nameof(CueViewModel.IsMultiSelected):
+ ComputeSelOutline();
+ break;
+ }
+ }
+
+ private void ComputeSelOutline()
+ {
+ if (vm == null)
+ return;
+
+ // Being multi-selected implies being selected. If not selected just hide the outline and return.
+ if (!vm.IsMultiSelected)
+ {
+ SelOutline.Visibility = Visibility.Collapsed;
+ return;
+ }
+ SelOutline.Visibility = Visibility.Visible;
+
+ if (vm.IsSelected)
+ {
+ // Debug.WriteLine($"Selected: {vm.FullQID}");
+ // Primary selection just gets a simple full outline
+ SelOutline.BorderThickness = new(1);
+ SelOutline.CornerRadius = defaultCornerRadius;
+ }
+ else
+ {
+ // Multi-selected cues try to join up their outlines into a single contiguous one.
+ var cues = vm.MainViewModel.Cues;
+ var multi = vm.MainViewModel.MultiSelection;
+ if (!cues.FindVisualIndex(vm, out var ind)) // Kinda expensive...
+ return;
+
+ bool top = ind == 0 || !multi.Contains(cues[ind - 1]);
+ bool bot = (ind + 1) >= cues.Count || !multi.Contains(cues[ind + 1]);
+
+ SelOutline.BorderThickness = new(1, top ? 1 : 0, 1, bot ? 1 : 0);
+ SelOutline.CornerRadius = new(
+ top ? defaultCornerRadius.TopLeft : 0,
+ top ? defaultCornerRadius.TopRight : 0,
+ bot ? defaultCornerRadius.BottomLeft : 0,
+ bot ? defaultCornerRadius.BottomRight : 0);
+ }
}
private void Grid_PreviewMouseDown(object sender, MouseButtonEventArgs e)
@@ -107,11 +253,11 @@ private void Grid_MouseMove(object sender, MouseEventArgs e)
base.OnMouseMove(e);
var delta = e.GetPosition(this) - startPos;
- if (DataContext is not CueViewModel vm)
+
+ if (vm == null)
return;
var mainVm = vm.MainViewModel;
- if (mainVm == null)
- return;
+
if (e.LeftButton == MouseButtonState.Pressed && delta.Length > DragDeadzone
&& mainVm.DraggingCues.Count == 0
&& !e.OriginalSource.GetType().IsAssignableTo(typeof(TextBox)))
@@ -123,7 +269,7 @@ private void Grid_MouseMove(object sender, MouseEventArgs e)
foreach (var cue in mainVm.Cues)
if (mainVm.MultiSelection.Contains(cue))
mainVm.DraggingCues.Add(cue);
- }
+ }
else
{
// Just add this cue
@@ -131,48 +277,79 @@ private void Grid_MouseMove(object sender, MouseEventArgs e)
}
data.SetData("Cues", mainVm.DraggingCues.ToArray());
- DragDrop.DoDragDrop(this, data, DragDropEffects.Move | DragDropEffects.Scroll);
+ // Debug.WriteLine($"Cue DragStart!");
+ DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Link | DragDropEffects.Move | DragDropEffects.Scroll);
- vm.MainViewModel?.DraggingCues?.Clear();
+ mainVm.DraggingCues.Clear();
}
}
- private void Grid_GiveFeedback(object sender, GiveFeedbackEventArgs e)
+ private void Grid_DragLeave(object sender, DragEventArgs e)
{
- base.OnGiveFeedback(e);
-
- if (e.Effects.HasFlag(DragDropEffects.Copy))
- Mouse.SetCursor(Cursors.Cross);
- else if (e.Effects.HasFlag(DragDropEffects.Move))
- Mouse.SetCursor(Cursors.Hand);
- else
- Mouse.SetCursor(Cursors.No);
-
- e.Handled = true;
+ InsertMarker.Visibility = Visibility.Collapsed;
+ GroupInsertMarker.Visibility = Visibility.Collapsed;
}
private void Grid_Drop(object sender, DragEventArgs e)
{
- base.OnDrop(e);
-
- InsertMarker.Visibility = Visibility.Collapsed;
+ ComputeDragEffects(sender, e);
+ Grid_DragLeave(sender, e);
+ if (vm != null)
+ MainWindow.HandleCueListDrop(e, vm.MainViewModel, vm);
+ e.Handled = true;
+ }
- if (DataContext is not CueViewModel targetVm)
+ private void ComputeDragEffects(object sender, DragEventArgs e)
+ {
+ if (vm == null)
return;
- var mainVm = targetVm.MainViewModel;
- if (mainVm != null)
- MainWindow.HandleCueListDrop(e, mainVm, targetVm);
- e.Handled = true;
+ if (!IsDropAllowed())
+ {
+ InsertMarker.Visibility = Visibility.Collapsed;
+ GroupInsertMarker.Visibility = Visibility.Collapsed;
+ e.Effects = DragDropEffects.None;
+ }
+ else if (IsGroupDragEffect(e))
+ {
+ InsertMarker.Visibility = Visibility.Collapsed;
+ GroupInsertMarker.Visibility = Visibility.Visible;
+ e.Effects = DragDropEffects.Link;
+ }
+ else
+ {
+ InsertMarker.Visibility = Visibility.Visible;
+ GroupInsertMarker.Visibility = Visibility.Collapsed;
+ if (e.KeyStates.HasFlag(DragDropKeyStates.ControlKey))
+ e.Effects = DragDropEffects.Copy;
+ else
+ e.Effects = DragDropEffects.Move;
+ }
+ }
+
+ private void Grid_GiveFeedback(object sender, GiveFeedbackEventArgs e)
+ {
+ // This event is handled by the MainWindow
+ base.OnGiveFeedback(e);
}
- private void Grid_DragEnter(object sender, DragEventArgs e)
+ private bool IsDropAllowed()
{
- InsertMarker.Visibility = Visibility.Visible;
+ if (vm == null)
+ return false;
+ var dragging = vm.MainViewModel.DraggingCues;
+ foreach (var cand in dragging)
+ if (vm.HasParent(cand))
+ return false;
+ if (dragging.Count == 1 && dragging[0] == vm)
+ return false;
+ return true;
}
- private void Grid_DragLeave(object sender, DragEventArgs e)
+ private bool IsGroupDragEffect(DragEventArgs e)
{
- InsertMarker.Visibility = Visibility.Collapsed;
+ var pos = e.GetPosition(this);
+ var height = ActualHeight;
+ return pos.Y > height * 0.5;
}
}
diff --git a/QPlayer/Views/CueDataTemplates.xaml b/QPlayer/Views/CueDataTemplates.xaml
index ebb561c..201408b 100644
--- a/QPlayer/Views/CueDataTemplates.xaml
+++ b/QPlayer/Views/CueDataTemplates.xaml
@@ -15,6 +15,7 @@
+
diff --git a/QPlayer/Views/CueEditor.xaml b/QPlayer/Views/CueEditor.xaml
index 11e297a..a7ff0ab 100644
--- a/QPlayer/Views/CueEditor.xaml
+++ b/QPlayer/Views/CueEditor.xaml
@@ -78,7 +78,12 @@
-
+
+
+
+
+
+
diff --git a/QPlayer/Views/ExpanderKnob.xaml b/QPlayer/Views/ExpanderKnob.xaml
new file mode 100644
index 0000000..d7418f1
--- /dev/null
+++ b/QPlayer/Views/ExpanderKnob.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer/Views/ExpanderKnob.xaml.cs b/QPlayer/Views/ExpanderKnob.xaml.cs
new file mode 100644
index 0000000..15de66b
--- /dev/null
+++ b/QPlayer/Views/ExpanderKnob.xaml.cs
@@ -0,0 +1,61 @@
+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.Views
+{
+ ///
+ /// Interaction logic for ExpanderKnob.xaml
+ ///
+ public partial class ExpanderKnob : UserControl
+ {
+ public bool IsCollapsed
+ {
+ get { return (bool)GetValue(IsExpandedProperty); }
+ set { SetValue(IsExpandedProperty, value); }
+ }
+
+ // Using a DependencyProperty as the backing store for IsExpanded. This enables animation, styling, binding, etc...
+ public static readonly DependencyProperty IsExpandedProperty =
+ DependencyProperty.Register(nameof(IsCollapsed), typeof(bool), typeof(ExpanderKnob), new PropertyMetadata(false, IsCollapsedChanged));
+
+ public ExpanderKnob()
+ {
+ InitializeComponent();
+ }
+
+ private void Grid_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ IsCollapsed ^= true;
+ }
+
+ private void UpdateVisual()
+ {
+ // At 0 degrees, the knob has a downwards (expanded) arrow
+ // At -90 degrees, the knob points right (collapsed)
+ ArrowRotateTransform.Angle = IsCollapsed ? -90 : 0;
+ }
+
+ private static void IsCollapsedChanged(DependencyObject target, DependencyPropertyChangedEventArgs e)
+ {
+ if (target is not ExpanderKnob inst)
+ return;
+
+ inst.UpdateVisual();
+ }
+
+ private void Grid_Loaded(object sender, RoutedEventArgs e)
+ {
+ UpdateVisual();
+ }
+ }
+}
diff --git a/QPlayer/Views/HiddenComboBox.xaml.cs b/QPlayer/Views/HiddenComboBox.xaml.cs
index ffba366..45b2954 100644
--- a/QPlayer/Views/HiddenComboBox.xaml.cs
+++ b/QPlayer/Views/HiddenComboBox.xaml.cs
@@ -25,6 +25,16 @@ public partial class HiddenComboBox : UserControl
public bool IsEditing => editing;
+ public bool CanEdit
+ {
+ get { return (bool)GetValue(CanEditProperty); }
+ set { SetValue(CanEditProperty, value); }
+ }
+
+ // Using a DependencyProperty as the backing store for CanEdit. This enables animation, styling, binding, etc...
+ public static readonly DependencyProperty CanEditProperty =
+ DependencyProperty.Register(nameof(CanEdit), typeof(bool), typeof(HiddenComboBox), new PropertyMetadata(true));
+
public HiddenComboBox()
{
InitializeComponent();
@@ -72,7 +82,7 @@ public int SelectedIndex
private void Edit()
{
- if (editing)
+ if (!CanEdit || editing)
return;
editing = true;
diff --git a/QPlayer/Views/HiddenTextbox.xaml b/QPlayer/Views/HiddenTextbox.xaml
index 78ec12a..7ccd7ed 100644
--- a/QPlayer/Views/HiddenTextbox.xaml
+++ b/QPlayer/Views/HiddenTextbox.xaml
@@ -7,9 +7,9 @@
x:Name="HiddenTextboxInst"
mc:Ignorable="d"
Focusable="True" IsTabStop="True" GotKeyboardFocus="HiddenTextboxInst_GotKeyboardFocus" KeyboardNavigation.TabNavigation="Local"
- d:DesignHeight="30" d:DesignWidth="200">
+ d:DesignHeight="30" d:DesignWidth="200" DataContextChanged="HiddenTextboxInst_DataContextChanged">
-
+
diff --git a/QPlayer/Views/HiddenTextbox.xaml.cs b/QPlayer/Views/HiddenTextbox.xaml.cs
index 8810fb1..3124883 100644
--- a/QPlayer/Views/HiddenTextbox.xaml.cs
+++ b/QPlayer/Views/HiddenTextbox.xaml.cs
@@ -11,6 +11,7 @@ namespace QPlayer.Views;
public partial class HiddenTextbox : UserControl
{
private bool editing = false;
+ private Binding? defaultPreviewTextBinding = null;
public bool IsEditing => editing;
@@ -28,12 +29,34 @@ public string Text
public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(HiddenTextbox), new FrameworkPropertyMetadata
{
BindsTwoWayByDefault = true,
- DefaultUpdateSourceTrigger = UpdateSourceTrigger.LostFocus
+ //DefaultUpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
+ //PropertyChangedCallback = TextPropertyChanged
});
+ public string? PreviewText
+ {
+ get { return (string)GetValue(PreviewTextProperty); }
+ set { SetValue(PreviewTextProperty, value); }
+ }
+
+ // Using a DependencyProperty as the backing store for PreviewText. This enables animation, styling, binding, etc...
+ public static readonly DependencyProperty PreviewTextProperty =
+ DependencyProperty.Register(nameof(PreviewText), typeof(string), typeof(HiddenTextbox), new PropertyMetadata(null));
+
+ public bool CanEdit
+ {
+ get { return (bool)GetValue(CanEditProperty); }
+ set { SetValue(CanEditProperty, value); }
+ }
+
+ // Using a DependencyProperty as the backing store for CanEdit. This enables animation, styling, binding, etc...
+ public static readonly DependencyProperty CanEditProperty =
+ DependencyProperty.Register(nameof(CanEdit), typeof(bool), typeof(HiddenTextbox), new PropertyMetadata(true));
+
+
private void Edit()
{
- if (editing)
+ if (!CanEdit || editing)
return;
editing = true;
TextFieldInst.Visibility = Visibility.Visible;
@@ -64,4 +87,19 @@ private void HiddenTextboxInst_GotKeyboardFocus(object sender, KeyboardFocusChan
{
Edit();
}
+
+ private void HiddenTextboxInst_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (BindingOperations.GetBinding(this, PreviewTextProperty) is Binding binding
+ && binding != defaultPreviewTextBinding)
+ {
+ // If the previewText has a valid binding, don't override it.
+ return;
+ }
+
+ // Make a new default binding
+ defaultPreviewTextBinding = new(nameof(Text));
+ defaultPreviewTextBinding.Source = this;
+ BindingOperations.SetBinding(this, PreviewTextProperty, defaultPreviewTextBinding);
+ }
}
diff --git a/QPlayer/Views/LogWindow.xaml b/QPlayer/Views/LogWindow.xaml
index ac36efd..1678062 100644
--- a/QPlayer/Views/LogWindow.xaml
+++ b/QPlayer/Views/LogWindow.xaml
@@ -21,7 +21,18 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer/Views/LogWindow.xaml.cs b/QPlayer/Views/LogWindow.xaml.cs
index 818cdb9..6378114 100644
--- a/QPlayer/Views/LogWindow.xaml.cs
+++ b/QPlayer/Views/LogWindow.xaml.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
using Microsoft.Win32;
using QPlayer.ViewModels;
@@ -14,6 +15,8 @@ public partial class LogWindow : Window
{
public MainViewModel ViewModel { get; init; }
private bool autoScrollToBottom;
+ private readonly SolidColorBrush errorBrush = new(Color.FromArgb(255, 220, 60, 40));
+ private readonly SolidColorBrush warningBrush = new(Color.FromArgb(255, 200, 220, 50));
public LogWindow(MainViewModel viewModel)
{
@@ -74,4 +77,15 @@ private void AudioBufferDbgCheckbox_Checked(object sender, RoutedEventArgs e)
AudioBufferDbgControl.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
LogListBox.Visibility = active ? Visibility.Collapsed : Visibility.Visible;
}
+
+ private void LogItemText_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not TextBlock text)
+ return;
+
+ if (text.Text.Contains("[Error]"))
+ text.Foreground = errorBrush;
+ else if (text.Text.Contains("[Warning]"))
+ text.Foreground = warningBrush;
+ }
}
diff --git a/QPlayer/Views/MainWindow.xaml b/QPlayer/Views/MainWindow.xaml
index d465c57..77dec43 100644
--- a/QPlayer/Views/MainWindow.xaml
+++ b/QPlayer/Views/MainWindow.xaml
@@ -10,7 +10,7 @@
mc:Ignorable="d"
x:Name="QPlayerMainWindow"
Title="{Binding WindowTitle}" Height="900" Width="1400" MinWidth="400" MinHeight="300" Style="{DynamicResource CustomWindowStyle}" Icon="{StaticResource IconImage}"
- Closing="Window_Closing" Loaded="Window_Loaded" DragOver="QPlayerMainWindow_DragOver" MouseMove="QPlayerMainWindow_MouseMove">
+ Closing="Window_Closing" Loaded="Window_Loaded" DragOver="QPlayerMainWindow_DragOver" DragEnter="QPlayerMainWindow_DragOver" MouseMove="QPlayerMainWindow_MouseMove">
@@ -27,6 +27,8 @@
+
+
@@ -39,8 +41,11 @@
+
+
+
@@ -51,7 +56,9 @@
+ Visibility="Visible" IsTabStop="False" Focusable="False" IsHitTestVisible="False"
+ VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" VirtualizingPanel.CacheLength="16"
+ VirtualizingPanel.CacheLengthUnit="Item">
@@ -71,10 +78,17 @@