diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index b821dae..ceb809d 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -474,35 +474,6 @@
Style="{StaticResource PropertyHint}" Margin="10,2,0,0"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -690,23 +661,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ToolTip="Play this clip in the preview from the beginning"/>
@@ -1085,6 +1038,34 @@
Margin="0,4,0,0"
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs
index 0c9927c..7401b2b 100644
--- a/Controls/LeftPanel.xaml.cs
+++ b/Controls/LeftPanel.xaml.cs
@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
+using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -178,8 +179,9 @@ public partial class LeftPanel : UserControl
var position = e.GetPosition(listBox);
if (!_isDragging)
{
- if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
- Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
+ if (!DragReorderMath.ExceededThreshold(
+ position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
+ SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
return;
_isDragging = true;
listBox.CaptureMouse();
@@ -193,12 +195,10 @@ public partial class LeftPanel : UserControl
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
- if (targetIndex < 0) return;
+ var landed = DragReorderMath.TryMove(items, _dragIndex, targetIndex);
+ if (landed < 0) return;
- var dragged = items[_dragIndex];
- items.RemoveAt(_dragIndex);
- items.Insert(targetIndex, dragged);
- _dragIndex = targetIndex;
+ _dragIndex = landed;
_lastHoveredItem = targetItem;
_dragReordered = true;
}
diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 67103b3..362eeb6 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -167,6 +167,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index 792ee0a..9357c11 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -28,8 +28,12 @@
## Landmines
-- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
- mouse input. Do not run it while the desktop is in use.
+- **The preview pane draws frame-source layers from a whitelist of DataTriggers.** A layer type with
+ no trigger is invisible in the preview while its decode looks healthy (alert box 2026-09-26, the
+ scoreboard, and the video layer 2026-11-05). Add the trigger with the layer type; pin it with a
+ test that asserts the bound Image is Visible (`MediaPreviewDisplayTests`).
+- The old physical mouse-drag test is **deleted**; the drag arithmetic it covered is now pure and
+ tested in `DragReorderMathTests`. Nothing in the suite drives the real cursor any more.
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw
path, and release through `ReleaseMediaIfUnused`.
diff --git a/Helpers/DragReorderMath.cs b/Helpers/DragReorderMath.cs
new file mode 100644
index 0000000..b01721d
--- /dev/null
+++ b/Helpers/DragReorderMath.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections;
+
+namespace ytLive.Helpers;
+
+///
+/// The arithmetic behind dragging a row in the layer list, kept out of the window's code-behind so it
+/// can be tested without a mouse. It used to live inline in the preview-mouse handlers, and the only
+/// test that covered it injected REAL cursor movement (SetCursorPos + mouse_event) — which
+/// took over whatever machine ran the tests and failed whenever the desktop was busy. 2026-11-05: the
+/// arithmetic moved here (pure, tested), the injected-input test was deleted.
+///
+public static class DragReorderMath
+{
+ /// True once the pointer has travelled far enough to mean "drag", not "click". WPF's
+ /// own thresholds are the caller's (SystemParameters.MinimumHorizontal/VerticalDragDistance).
+ public static bool ExceededThreshold(double dx, double dy, double horizontal, double vertical)
+ => Math.Abs(dx) >= horizontal || Math.Abs(dy) >= vertical;
+
+ /// Moves one item to the position currently occupied by another. Returns the index the
+ /// item actually landed on (the caller keeps dragging from there), or -1 when the move is a no-op
+ /// or the indices are out of range.
+ /// Order matters: the item is removed first, so a downward move inserts at the target's old
+ /// index and lands exactly under the pointer — this is the behaviour the mouse test used to pin.
+ ///
+ public static int TryMove(IList items, int from, int to)
+ {
+ if (items == null || from < 0 || to < 0 || from >= items.Count || to >= items.Count) return -1;
+ if (from == to) return -1;
+
+ var moved = items[from]!;
+ items.RemoveAt(from);
+ items.Insert(to, moved);
+ return to;
+ }
+}
diff --git a/Models/Source.cs b/Models/Source.cs
index 26ca81f..7012be3 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -256,10 +256,7 @@ public class Source : SceneElement
public bool MediaIsLooping
{
get => _mediaIsLooping;
- set
- {
- if (Set(ref _mediaIsLooping, value)) Raise(nameof(CanPlayMedia));
- }
+ set => Set(ref _mediaIsLooping, value);
}
private double _mediaVolume = 1.0;
@@ -293,9 +290,6 @@ public class Source : SceneElement
public bool IsMediaFinished => !IsMediaRunning;
- /// Play is a manual, one-shot action: while Loop is on the clip restarts itself, so the
- /// button would be a no-op (creator ruling 2026-11-05 — the button is beside the Video File label).
- public bool CanPlayMedia => !MediaIsLooping;
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
public MediaPlaybackState MediaPlaybackState
diff --git a/Services/ChromaKeyDetector.cs b/Services/ChromaKeyDetector.cs
index 21358f0..d76bb11 100644
--- a/Services/ChromaKeyDetector.cs
+++ b/Services/ChromaKeyDetector.cs
@@ -45,7 +45,11 @@ public static class ChromaKeyDetector
// count against an otherwise perfect screen.
private const double MinSaturation = 0.35;
private const double MinValue = 0.15;
- private const double MaxValue = 0.98;
+
+ // No upper brightness bound ON PURPOSE (found on a real clip 2026-11-05): a clean painted
+ // screen is fully saturated, so its value sits at ~1.0 and a 0.98 ceiling rejected the
+ // greenest possible screen. White highlights are excluded by the SATURATION floor instead,
+ // which is the property that actually separates them from a screen.
// The screen's own pixels must agree with each other: a painted surface is flat, a lawn
// is not. This is what keeps "green field" from being keyed.
@@ -82,7 +86,7 @@ public static class ChromaKeyDetector
if (frame.BgraPixels[i + 3] < 8) continue; // transparent: not the backdrop
var (hue, saturation, value) = ToHsv(r, g, b);
- if (saturation < MinSaturation || value < MinValue || value > MaxValue) continue;
+ if (saturation < MinSaturation || value < MinValue) continue;
considered++;
if (hue is >= GreenHueFrom and <= GreenHueTo) green++;
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 55c9fd5..094cbf0 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -1,9 +1,29 @@
+## Preview pane — frame-source layers are a WHITELIST
+
+`PreviewPane.xaml`'s per-layer `Image` is **Collapsed by default** and switched on by one DataTrigger
+per layer type that renders a frame (`IsImageSource`, `IsChatBox`, `IsAlertBox`, `IsTicker`,
+`IsScoreboard`, `IsTextOverlay`, `IsWebSource`, the webcam variants, and — since 2026-11-05 —
+`IsMediaSource` + `IsRectShape`). **A new frame-source layer type that forgets its trigger is
+invisible in the preview while its decode logs healthy.** That has now bitten the alert box
+(2026-09-26), the scoreboard, and the video layer. Add the trigger in the same commit as the layer
+type, and pin it with a test that asserts the bound `Image` is `Visible` — see
+`ytLive.Tests/MediaPreviewDisplayTests.cs`.
+
# UI surface: control room, patterns & transitions
The control-surface layout, scene strip, preview rail, hotkeys, broadcast drawer, toasts,
transitions, brand logo, launch geometry, and the theme/properties-panel conventions.
Read before changing anything the creator looks at.
+**Frame-source layers are drawn by a WHITELIST (2026-11-05).** `PreviewPane.xaml`'s per-layer `Image`
+is `Collapsed` by default and switched on by one DataTrigger per layer type that renders a frame
+(`IsImageSource`, `IsChatBox`, `IsAlertBox`, `IsTicker`, `IsScoreboard`, `IsTextOverlay`,
+`IsWebSource`, webcam variants, and — since 2026-11-05 — `IsMediaSource` with `IsRectShape`).
+**A new frame-source layer type that forgets its trigger is invisible in the preview while the
+decode logs healthy.** This has now bitten the alert box (2026-09-26), the scoreboard, and the video
+layer, so add the trigger in the same commit as the layer type, and pin it with a test that asserts
+the bound `Image` is `Visible` (see `MediaPreviewDisplayTests`).
+
## Launch POSITION is derived, never restored (2026-10-04)
Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because
diff --git a/scripts/verify.sh b/scripts/verify.sh
index dc7a8d5..90d47cb 100644
--- a/scripts/verify.sh
+++ b/scripts/verify.sh
@@ -65,7 +65,7 @@ echo "$RUN_RAW" | grep -iE "Passed!|Failed!" | tail -1
# Known pre-existing failures (see HANDOFF.md: do not chase). Anything beyond
# these is a real regression and fails the gate.
FAILS="$(echo "$RUN_RAW" | grep -iE "\[FAIL\]" || true)"
-KNOWN=$(echo "$FAILS" | grep -cE "Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback|Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle|RealMouseDrag_OnTheLayerList_PersistsTheReorder" || true)
+KNOWN=$(echo "$FAILS" | grep -cE "Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback|Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle" || true)
echo "$FAILS" | sort -u || true
if [ "$TEST_EXIT" -ne 0 ] && [ "$KNOWN" -eq 0 ]; then
diff --git a/ytLive.Tests/ChromaKeyRealClipTests.cs b/ytLive.Tests/ChromaKeyRealClipTests.cs
new file mode 100644
index 0000000..2b96a09
--- /dev/null
+++ b/ytLive.Tests/ChromaKeyRealClipTests.cs
@@ -0,0 +1,87 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using ytLive.Services;
+using ytLive.Services.Encoder;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 64 against a REAL clip (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080, 5 s, a
+/// subject moving over a green field). The detector had only ever seen synthetic frames; this runs
+/// the bundled ffmpeg, our probe, our detector, and our keyed decode on actual video.
+/// It also pins the decision that the key colour comes from the measured screen rather than a
+/// canonical green — on this clip the field is #00FE00 and a hardcoded #00B140 keys nothing
+/// (measured: 0.0% of pixels transparent), while the detected colour keys 97.8%.
+///
+public class ChromaKeyRealClipTests
+{
+ private const string ClipName = "test_greenscreen_moving_subject.mp4";
+
+ private static string ClipPath => Path.Combine(AppContext.BaseDirectory, "Fixtures", ClipName);
+
+ private static string FfmpegPath => Path.Combine(AppContext.BaseDirectory, "ffmpeg", "ffmpeg.exe");
+
+ [Fact]
+ public async Task RealGreenScreenClip_IsDetected_AndKeysWithTheMeasuredColour()
+ {
+ Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}");
+ Assert.True(File.Exists(FfmpegPath), $"the bundled ffmpeg should sit beside the test binary: {FfmpegPath}");
+
+ // 1. The probe reads one real frame of the clip.
+ var probe = new FfmpegChromaProbe(new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess());
+ var frame = await probe.ProbeAsync(ClipPath);
+ Assert.NotNull(frame);
+
+ // 2. The detector says "green screen", with high confidence.
+ var suggestion = ChromaKeyDetector.Detect(frame!);
+ Assert.Equal(ChromaScreen.Green, suggestion.Screen);
+ Assert.True(suggestion.IsHighConfidence,
+ $"a clean green field should be high confidence, got {suggestion.Confidence:0.00}");
+ Assert.NotNull(suggestion.ColorHex);
+
+ // …and the colour it names is the field as shot: green-dominant, almost no red or blue.
+ var (r, g, b) = ParseHex(suggestion.ColorHex!);
+ Assert.True(g > 0xE0 && r < 0x20 && b < 0x20,
+ $"the detected colour should be the measured field, got {suggestion.ColorHex}");
+
+ // 3. That colour actually keys the clip through our own pipe: most pixels go transparent,
+ // the moving subject survives.
+ var keyed = ChromaKeyOptions.For(suggestion);
+ var firstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var pipe = new VideoClipPipe(
+ ClipPath, 320, 180, new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess(),
+ audio: false, chromaKey: keyed);
+ pipe.FrameAvailable += f => firstFrame.TrySetResult(f);
+
+ await pipe.StartAsync();
+ var keyedFrame = await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ await pipe.StopAsync();
+
+ var transparent = 0;
+ for (var i = 3; i < keyedFrame.BgraPixels.Length; i += 4)
+ if (keyedFrame.BgraPixels[i] == 0) transparent++;
+
+ var ratio = (double)transparent / (keyedFrame.Width * keyedFrame.Height);
+ Assert.True(ratio > 0.5,
+ $"the keyed decode left only {ratio:P1} transparent — the filter chain is not keying this clip");
+ Assert.True(ratio < 1.0, "everything vanished; the subject should survive the key");
+ }
+
+ private static (int R, int G, int B) ParseHex(string hex)
+ {
+ var value = hex.TrimStart('#');
+ return (Convert.ToInt32(value[..2], 16), Convert.ToInt32(value.Substring(2, 2), 16),
+ Convert.ToInt32(value.Substring(4, 2), 16));
+ }
+
+ private sealed class FixedLocator : IFfmpegLocator
+ {
+ private readonly string _path;
+ public FixedLocator(string path) => _path = path;
+ public Task LocateAsync(CancellationToken ct = default) => Task.FromResult(_path);
+ }
+}
diff --git a/ytLive.Tests/DragReorderMathTests.cs b/ytLive.Tests/DragReorderMathTests.cs
new file mode 100644
index 0000000..6bccdad
--- /dev/null
+++ b/ytLive.Tests/DragReorderMathTests.cs
@@ -0,0 +1,70 @@
+using System.Collections;
+using Xunit;
+using ytLive.Helpers;
+
+namespace ytLive.Tests;
+
+///
+/// The layer-list drag arithmetic, tested without a mouse. It replaced a test that injected REAL
+/// cursor movement into the desktop — which hijacked the machine and failed whenever it was busy
+/// (deleted 2026-11-05). Same behaviour covered here, in microseconds, with no input device.
+///
+public class DragReorderMathTests
+{
+ [Theory]
+ [InlineData(0, 0, false)] // a click, not a drag
+ [InlineData(3, 2, false)] // inside both thresholds (4 px is WPF's default)
+ [InlineData(4, 0, true)] // exactly the threshold counts — WPF's own rule
+ [InlineData(0, 4, true)]
+ [InlineData(-9, 1, true)] // direction is irrelevant
+ public void Threshold_DecidesDragVersusClick(double dx, double dy, bool dragging)
+ => Assert.Equal(dragging, DragReorderMath.ExceededThreshold(dx, dy, 4, 4));
+
+ [Fact]
+ public void MovingDown_LandsOnTheTargetsIndex()
+ {
+ var items = new ArrayList { "A", "B", "C" };
+
+ var landed = DragReorderMath.TryMove(items, 0, 2);
+
+ Assert.Equal(2, landed);
+ Assert.Equal(new[] { "B", "C", "A" }, items.Cast());
+ }
+
+ [Fact]
+ public void MovingUp_LandsOnTheTargetsIndex()
+ {
+ var items = new ArrayList { "A", "B", "C" };
+
+ var landed = DragReorderMath.TryMove(items, 2, 0);
+
+ Assert.Equal(0, landed);
+ Assert.Equal(new[] { "C", "A", "B" }, items.Cast());
+ }
+
+ [Fact]
+ public void DraggingOntoItself_IsANoOp()
+ {
+ var items = new ArrayList { "A", "B", "C" };
+
+ Assert.Equal(-1, DragReorderMath.TryMove(items, 1, 1));
+ Assert.Equal(new[] { "A", "B", "C" }, items.Cast());
+ }
+
+ [Theory]
+ [InlineData(-1, 0)]
+ [InlineData(0, -1)]
+ [InlineData(3, 0)]
+ [InlineData(0, 3)]
+ public void OutOfRangeIndices_ChangeNothing(int from, int to)
+ {
+ var items = new ArrayList { "A", "B", "C" };
+
+ Assert.Equal(-1, DragReorderMath.TryMove(items, from, to));
+ Assert.Equal(new[] { "A", "B", "C" }, items.Cast());
+ }
+
+ [Fact]
+ public void EmptyList_IsSafe()
+ => Assert.Equal(-1, DragReorderMath.TryMove(new ArrayList(), 0, 0));
+}
diff --git a/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 b/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4
new file mode 100644
index 0000000..e7e628c
Binary files /dev/null and b/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 differ
diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs
index e6c7950..1d06bae 100644
--- a/ytLive.Tests/LayerReorderPersistenceTests.cs
+++ b/ytLive.Tests/LayerReorderPersistenceTests.cs
@@ -54,122 +54,6 @@ public sealed class LayerReorderPersistenceTests
_app.Run(RunCrossTypeReload);
}
- ///
- /// Same use case as above but driven with REAL injected mouse input
- /// (SetCursorPos + mouse_event against the desktop) so the actual
- /// List_PreviewMouseLeftButtonDown/Move/Up handlers — the seam no synthetic
- /// event can exercise, since e.GetPosition reads the physical cursor — must
- /// run. Requires an interactive desktop session: if the window is covered or
- /// the session is locked, the pointer no-ops and the drag never lands.
- ///
- [Fact]
- public void RealMouseDrag_OnTheLayerList_PersistsTheReorder()
- {
- _app.Run(RunRealDrag);
- }
-
- private void RunRealDrag()
- {
- var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-realdrag-{Guid.NewGuid():N}.db");
- MainViewModel.LayoutPathOverride = tempDb;
- var window = default(MainWindow);
- try
- {
- using (var schema = new LayoutStore(tempDb)) { }
- SqliteConnection.ClearAllPools();
- SeedThreeLayers(tempDb);
-
- window = new MainWindow();
- var vm = (MainViewModel)window.DataContext;
- vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
- Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4,
- "3 seeded layers + the healed Background + Brand logo must load");
-
- window.Show();
- window.Activate();
- window.Topmost = true;
- window.UpdateLayout();
-
- var panel = (UserControl)window.FindName("LeftPanel")!;
- var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
- list.UpdateLayout();
-
- // Brand logo is a permanent layer between Background and user layers.
- // Physically drag ImgC above ImgA; the background and logo keep their
- // relative positions while EndListDrag persists the user-layer reorder.
- var sources = vm.StagedScene.Elements.OfType().ToArray();
- var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
- var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
-
- SetCursorPos((int)from.X, (int)from.Y);
- LetInputSettle(80);
- mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
- LetInputSettle(120);
- SetCursorPos((int)to.X, (int)to.Y);
- LetInputSettle(250);
- mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
- LetInputSettle(400);
-
- var expected = vm.StagedScene.Elements.OfType().Select(s => s.Name).ToArray();
- Assert.Equal("Background", expected[0]);
- Assert.Equal("Brand logo", expected[1]);
- Assert.Equal("ImgC", expected[2]);
- Assert.True(Array.IndexOf(expected, "ImgC") < Array.IndexOf(expected, "ImgA"));
-
- Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
- TimeSpan.FromSeconds(10)),
- "a real mouse drag-and-drop must be persisted by the background save");
- Assert.Equal(expected, SourceNamesInDb(tempDb));
-
- // The same drop must persist the PREVIEW LAYOUT too — each layer's
- // geometry (X/Y/W/H) rides in the same Source rows as the SortOrder.
- var dragged = vm.StagedScene.Elements.OfType().Single(s => s.Name == "ImgC");
- Assert.Equal((dragged.X, dragged.Y, dragged.Width, dragged.Height),
- GeometryInDb(tempDb, "ImgC"));
- }
- finally
- {
- if (window != null) window.Close();
- MainViewModel.LayoutPathOverride = null;
- SqliteConnection.ClearAllPools();
- try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
- }
- }
-
- /// Screen (device-pixel) point one-third into an item's list row — over
- /// the name TextBlock, away from the per-row buttons/rename TextBox.
- private static Point RowScreenPoint(FocusPreservingListBox list, Models.SceneElement item)
- {
- var container = (ListBoxItem)list.ItemContainerGenerator.ContainerFromItem(item)!;
- var inList = container.TransformToVisual(list)
- .Transform(new Point(Math.Min(30, container.ActualWidth - 12), container.ActualHeight / 2));
- return list.PointToScreen(inList);
- }
-
- /// Pumps the app dispatcher while sleeping in small steps, so real
- /// input messages posted to the thread queue are processed mid-gesture.
- private static void LetInputSettle(int milliseconds)
- {
- var frame = new DispatcherFrame();
- var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(milliseconds) };
- timer.Tick += (_, _) =>
- {
- timer.Stop();
- frame.Continue = false;
- };
- timer.Start();
- Dispatcher.PushFrame(frame);
- }
-
- private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
- private const uint MOUSEEVENTF_LEFTUP = 0x0004;
-
- [DllImport("user32.dll")]
- private static extern bool SetCursorPos(int x, int y);
-
- [DllImport("user32.dll")]
- private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
-
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db");
diff --git a/ytLive.Tests/MediaPreviewDisplayTests.cs b/ytLive.Tests/MediaPreviewDisplayTests.cs
new file mode 100644
index 0000000..cea97ea
--- /dev/null
+++ b/ytLive.Tests/MediaPreviewDisplayTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// 2026-11-05 report: a video layer added and playing showed NOTHING in the preview pane. The decode
+/// was healthy — the preview's Image is switched on by a whitelist of DataTriggers (image,
+/// chat, alert, ticker, scoreboard, text, web, webcam) and a video layer matched none of them, so its
+/// Image stayed Collapsed. Same class of defect as the invisible alert box (2026-09-26).
+/// These assert the two ends: the layer receives frames, and the pane actually shows it.
+///
+[Collection("RealApp")]
+public sealed class MediaPreviewDisplayTests
+{
+ private readonly RealAppHost _app;
+
+ public MediaPreviewDisplayTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void VideoLayer_IsDrawnByThePreviewPane()
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ var videoPath = Path.Combine(Path.GetTempPath(), "preview-clip.mp4");
+ var decoders = new System.Collections.Generic.List();
+ MainViewModel.LayoutPathOverride = tempDb;
+ MainViewModel.MediaFrameSourceFactoryOverride = key =>
+ {
+ var decoder = new FakeMediaSource(key);
+ decoders.Add(decoder);
+ return decoder;
+ };
+ MainViewModel.ChromaProbeOverride = new NoProbe();
+
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+
+ vm.AddMediaSource(videoPath);
+ var layer = vm.StagedScene!.Elements.OfType().Single(s => s.IsMediaSource);
+
+ // The layer receives a decoded frame…
+ var frame = SolidFrame(64, 36);
+ decoders.Single().Emit(frame);
+ Assert.NotNull(layer.DisplaySource);
+
+ window.UpdateLayout();
+
+ // …and the preview pane actually renders it. Without the IsMediaSource trigger the
+ // Image is Collapsed and the clip is invisible while everything else looks healthy.
+ var pane = (UserControl)window.FindName("PreviewPane")!;
+ var image = FindImageBoundTo(pane, layer.DisplaySource!);
+ Assert.True(image != null,
+ "the preview pane has no Image bound to the video layer's frame — the layer is invisible");
+ Assert.Equal(Visibility.Visible, image!.Visibility);
+ Assert.True(image.ActualWidth > 0 && image.ActualHeight > 0,
+ $"the video layer's preview Image has no size ({image.ActualWidth}x{image.ActualHeight})");
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.ChromaProbeOverride = null;
+ MainViewModel.MediaFrameSourceFactoryOverride = null;
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+
+ private static Image? FindImageBoundTo(DependencyObject parent, ImageSource source)
+ {
+ for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (child is Image image && ReferenceEquals(image.Source, source)) return image;
+ if (FindImageBoundTo(child, source) is { } found) return found;
+ }
+ return null;
+ }
+
+ private static VideoFrame SolidFrame(int w, int h)
+ {
+ var bytes = new byte[w * h * 4];
+ for (var i = 0; i < bytes.Length; i += 4)
+ {
+ bytes[i] = 40; bytes[i + 1] = 200; bytes[i + 2] = 40; bytes[i + 3] = 255;
+ }
+ return new VideoFrame(w, h, bytes);
+ }
+
+ private sealed class FakeMediaSource : IMediaFrameSource
+ {
+ public FakeMediaSource(string key) => Key = key;
+ public string Key { get; }
+ public bool Looping { get; set; }
+ public event Action? FrameAvailable;
+ public event Action? AudioAvailable { add { } remove { } }
+ public event Action? Completed { add { } remove { } }
+ public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
+ public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task StopAsync() => Task.CompletedTask;
+ public void Dispose() { }
+ }
+
+ private sealed class NoProbe : IChromaProbe
+ {
+ public Task ProbeAsync(string path, CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+ }
+}