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 @@ - - + + + + + + + + -