From 4914583e9e401b98d0b0fe84772911fe55604219 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:41:07 -0500 Subject: [PATCH 1/2] Click a player (incl. mini previews) to switch to that camera --- SentryReplay/MainWindow.xaml | 29 +++++++++++++++++++++++++---- SentryReplay/MainWindow.xaml.cs | 11 +++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index bb1dad9..9692c89 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -419,10 +419,31 @@ - - - - + + + + + + + + + + + + + diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index 524444e..768941f 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -113,6 +113,17 @@ private void OnSearchBoxFocusRequested(object sender, EventArgs e) SearchBox.SelectAll(); } + private void CameraHost_Click(object sender, MouseButtonEventArgs e) + { + // Clicking a player (including the mini previews) switches to that camera. A transparent overlay + // inside the host receives the click that the native video surface would otherwise swallow. + if (sender is FrameworkElement { Tag: string cameraView }) + { + _viewModel.SelectCameraViewCommand.Execute(cameraView); + e.Handled = true; + } + } + private void UpdateCameraHostLayout() { if (PrimaryCameraHostSlot is null) From 9604d7f40564bda9b6004c8e4b9d195b282d4c59 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:54:09 -0500 Subject: [PATCH 2/2] Prototype: unified camera panel (no reparenting) --- SentryReplay/CameraLayoutPanel.cs | 117 +++++++++++++++++++++ SentryReplay/MainWindow.xaml | 166 ++++-------------------------- SentryReplay/MainWindow.xaml.cs | 109 +------------------- 3 files changed, 138 insertions(+), 254 deletions(-) create mode 100644 SentryReplay/CameraLayoutPanel.cs diff --git a/SentryReplay/CameraLayoutPanel.cs b/SentryReplay/CameraLayoutPanel.cs new file mode 100644 index 0000000..023db36 --- /dev/null +++ b/SentryReplay/CameraLayoutPanel.cs @@ -0,0 +1,117 @@ +using System.Windows; +using System.Windows.Controls; + +namespace SentryReplay; + +/// +/// Arranges the four camera hosts without ever reparenting them: a 2x2 grid, or one primary view filling +/// the top with the other three as a strip of tiles along the bottom. Switching views only re-arranges, +/// so each Flyleaf surface resizes in place (no reparent flash). Identify each child with the attached +/// ; drive the layout with . +/// +public sealed class CameraLayoutPanel : Panel +{ + private const double Gap = 2; + private const double TileStripFraction = 0.22; + + private static readonly string[] CameraOrder = + [ + MainWindowViewModel.FrontCameraView, + MainWindowViewModel.RearCameraView, + MainWindowViewModel.LeftCameraView, + MainWindowViewModel.RightCameraView, + ]; + + public static readonly DependencyProperty CameraProperty = DependencyProperty.RegisterAttached( + "Camera", + typeof(string), + typeof(CameraLayoutPanel), + new PropertyMetadata(null)); + + public static string GetCamera(DependencyObject element) => (string)element.GetValue(CameraProperty); + + public static void SetCamera(DependencyObject element, string value) => element.SetValue(CameraProperty, value); + + public static readonly DependencyProperty SelectedCameraViewProperty = DependencyProperty.Register( + nameof(SelectedCameraView), + typeof(string), + typeof(CameraLayoutPanel), + new FrameworkPropertyMetadata(MainWindowViewModel.FrontCameraView, FrameworkPropertyMetadataOptions.AffectsArrange)); + + public string SelectedCameraView + { + get => (string)GetValue(SelectedCameraViewProperty); + set => SetValue(SelectedCameraViewProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) + { + foreach (UIElement child in InternalChildren) + { + child.Measure(availableSize); + } + + return availableSize; + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (SelectedCameraView == MainWindowViewModel.GridCameraView) + { + ArrangeGrid(finalSize); + } + else + { + ArrangeSingle(finalSize); + } + + return finalSize; + } + + private void ArrangeGrid(Size size) + { + var cellWidth = (size.Width - Gap) / 2; + var cellHeight = (size.Height - Gap) / 2; + var right = cellWidth + Gap; + var bottom = cellHeight + Gap; + + ArrangeCamera(MainWindowViewModel.FrontCameraView, new Rect(0, 0, cellWidth, cellHeight)); + ArrangeCamera(MainWindowViewModel.RearCameraView, new Rect(right, 0, cellWidth, cellHeight)); + ArrangeCamera(MainWindowViewModel.LeftCameraView, new Rect(0, bottom, cellWidth, cellHeight)); + ArrangeCamera(MainWindowViewModel.RightCameraView, new Rect(right, bottom, cellWidth, cellHeight)); + } + + private void ArrangeSingle(Size size) + { + var stripHeight = size.Height * TileStripFraction; + var primaryHeight = Math.Max(0, size.Height - stripHeight - Gap); + + ArrangeCamera(SelectedCameraView, new Rect(0, 0, size.Width, primaryHeight)); + + var tiles = CameraOrder.Where(camera => camera != SelectedCameraView).ToArray(); + if (tiles.Length == 0) + { + return; + } + + var tileWidth = (size.Width - (Gap * (tiles.Length - 1))) / tiles.Length; + var tileTop = primaryHeight + Gap; + + for (var i = 0; i < tiles.Length; i++) + { + ArrangeCamera(tiles[i], new Rect(i * (tileWidth + Gap), tileTop, tileWidth, stripHeight)); + } + } + + private void ArrangeCamera(string camera, Rect rect) + { + foreach (UIElement child in InternalChildren) + { + if (GetCamera(child) == camera) + { + child.Arrange(rect); + return; + } + } + } +} diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index 9692c89..1fbbb39 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -417,35 +417,6 @@ - - - - - - - - - - - - - - - - @@ -460,113 +431,16 @@ BorderThickness="1" ClipToBounds="True" CornerRadius="6"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - + + CornerRadius="3" /> - + + CornerRadius="3" /> - + + CornerRadius="3" /> - + + CornerRadius="3" /> -/// Main WPF window. Owns only view concerns: window lifecycle, Flyleaf host layout, the seek -/// slider input plumbing, and search-box focus. All state, commands, and orchestration live in -/// . +/// Main WPF window. Owns only view concerns: window lifecycle, the seek-slider input plumbing, and +/// search-box focus. The camera layout is handled by (no reparenting); +/// all state, commands, and orchestration live in . /// public partial class MainWindow : Window { @@ -27,10 +27,8 @@ public MainWindow() _viewModel = new MainWindowViewModel( () => VideoPlayerController.Create(FrontFlyleafHost, BackFlyleafHost, LeftFlyleafHost, RightFlyleafHost)); _viewModel.SearchBoxFocusRequested += OnSearchBoxFocusRequested; - _viewModel.PropertyChanged += ViewModelOnPropertyChanged; DataContext = _viewModel; - UpdateCameraHostLayout(); } private async void Window_ContentRendered(object sender, EventArgs e) @@ -99,113 +97,12 @@ private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventAr _viewModel.OnSeekSliderValueChanged(); } - private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(MainWindowViewModel.SelectedCameraView)) - { - UpdateCameraHostLayout(); - } - } - private void OnSearchBoxFocusRequested(object sender, EventArgs e) { SearchBox.Focus(); SearchBox.SelectAll(); } - private void CameraHost_Click(object sender, MouseButtonEventArgs e) - { - // Clicking a player (including the mini previews) switches to that camera. A transparent overlay - // inside the host receives the click that the native video surface would otherwise swallow. - if (sender is FrameworkElement { Tag: string cameraView }) - { - _viewModel.SelectCameraViewCommand.Execute(cameraView); - e.Handled = true; - } - } - - private void UpdateCameraHostLayout() - { - if (PrimaryCameraHostSlot is null) - return; - - foreach (var (host, slot) in GetCameraHostLayout(_viewModel.SelectedCameraView)) - { - MoveHostToSlot(host, slot); - } - - // Force a synchronous layout pass so each moved host reaches its final bounds promptly. Note: a - // brief flash of the reparented Flyleaf surface at its old size is a known limitation here and is - // accepted (eliminating it would require not reparenting the hosts at all). - UpdateLayout(); - } - - private IReadOnlyList<(FlyleafHost Host, ContentControl Slot)> GetCameraHostLayout(string view) => view switch - { - MainWindowViewModel.GridCameraView => - [ - (FrontFlyleafHost, GridFrontHostSlot), - (BackFlyleafHost, GridRearHostSlot), - (LeftFlyleafHost, GridLeftHostSlot), - (RightFlyleafHost, GridRightHostSlot), - ], - MainWindowViewModel.RearCameraView => - [ - (BackFlyleafHost, PrimaryCameraHostSlot), - (FrontFlyleafHost, FrontTileHostSlot), - (LeftFlyleafHost, LeftTileHostSlot), - (RightFlyleafHost, RightTileHostSlot), - ], - MainWindowViewModel.LeftCameraView => - [ - (LeftFlyleafHost, PrimaryCameraHostSlot), - (FrontFlyleafHost, FrontTileHostSlot), - (BackFlyleafHost, RearTileHostSlot), - (RightFlyleafHost, RightTileHostSlot), - ], - MainWindowViewModel.RightCameraView => - [ - (RightFlyleafHost, PrimaryCameraHostSlot), - (FrontFlyleafHost, FrontTileHostSlot), - (BackFlyleafHost, RearTileHostSlot), - (LeftFlyleafHost, LeftTileHostSlot), - ], - _ => - [ - (FrontFlyleafHost, PrimaryCameraHostSlot), - (BackFlyleafHost, RearTileHostSlot), - (LeftFlyleafHost, LeftTileHostSlot), - (RightFlyleafHost, RightTileHostSlot), - ], - }; - - private static void MoveHostToSlot(FlyleafHost host, ContentControl slot) - { - if (ReferenceEquals(slot.Content, host)) - return; - - RemoveHostFromParent(host); - slot.Content = host; - } - - private static void RemoveHostFromParent(FlyleafHost host) - { - switch (host.Parent) - { - case ContentControl contentControl when ReferenceEquals(contentControl.Content, host): - contentControl.Content = null; - break; - - case Panel panel: - panel.Children.Remove(host); - break; - - case Decorator decorator when ReferenceEquals(decorator.Child, host): - decorator.Child = null; - break; - } - } - private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) { if (e.Uri is null)