diff --git a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs index 410aa94..a50983f 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/MainWindowViewModel.cs @@ -33,6 +33,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase, IRecipient CurrentPage is GridPageViewModel; public bool IsLibrarySelected => CurrentPage is CameraLibraryPageViewModel or SingleCameraPageViewModel; public bool IsRecordingsSelected => CurrentPage is RecordingsPageViewModel; @@ -71,6 +79,26 @@ public MainWindowViewModel( OverlayDialogPresenter.ActiveChanged += () => NavigateCommand.NotifyCanExecuteChanged(); } + public void SetViewportOrientation(bool isMobileLandscape) + { + if (_isMobileLandscape == isMobileLandscape) + return; + _isMobileLandscape = isMobileLandscape; + UpdateFullscreen(); + } + + partial void OnCurrentPageChanged(ViewModelBase value) => UpdateFullscreen(); + + private void UpdateFullscreen() + { + IsFullscreen = _isMobileLandscape && CurrentPage is SingleCameraPageViewModel; + // Camera-to-camera swipe replaces the page VM while IsFullscreen stays + // true, so the flag is pushed to the current page explicitly instead + // of relying on the property-changed callback. + if (CurrentPage is SingleCameraPageViewModel camera) + camera.IsFullscreen = IsFullscreen; + } + private static bool CanNavigate() => !OverlayDialogPresenter.IsAnyOpen; [RelayCommand(CanExecute = nameof(CanNavigate))] diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 3c536cd..261b3c2 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -68,6 +68,11 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis public bool IsConnecting => Session is not null && State is SessionState.Connecting or SessionState.Reconnecting; public bool IsFailed => State == SessionState.Failed; + // Set by MainWindowViewModel while the device is in mobile landscape — + // hides the header / Majestic panel / bottom bar so the video gets the + // whole screen. Overlays (LIVE badge, telemetry, PTZ) stay on the video. + [ObservableProperty] private bool _isFullscreen; + [ObservableProperty] private string? _snapshotPath; [ObservableProperty] private PtzController? _ptz; [ObservableProperty] private string _newPresetName = ""; diff --git a/src/OpenIPC.Viewer.App/Views/MainView.axaml b/src/OpenIPC.Viewer.App/Views/MainView.axaml index 4a37b33..e43d0ff 100644 --- a/src/OpenIPC.Viewer.App/Views/MainView.axaml +++ b/src/OpenIPC.Viewer.App/Views/MainView.axaml @@ -15,7 +15,9 @@ Responsive layout. The split happens at 700px: wide → desktop sidebar on the left, content fills the rest narrow → bottom-nav under the content (mobile / pinned-narrow window) - IsWideLayout is updated by code-behind on every SizeChanged. + ShowSidebar / ShowBottomNav are updated by code-behind on every SizeChanged + and both collapse while the mobile landscape-fullscreen camera view is + active (MainWindowViewModel.IsFullscreen). CurrentPage is hosted in a SINGLE ContentControl shared by both layouts. Hosting it twice (one ContentControl per layout) made the same page VM bind @@ -29,7 +31,7 @@ @@ -96,7 +98,7 @@ + IsVisible="{Binding #Root.ShowBottomNav}" /> diff --git a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs index 3d22f12..6a03480 100644 --- a/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/MainView.axaml.cs @@ -1,5 +1,8 @@ +using System; +using System.ComponentModel; using Avalonia; using Avalonia.Controls; +using OpenIPC.Viewer.App.ViewModels; namespace OpenIPC.Viewer.App.Views; @@ -10,13 +13,18 @@ public partial class MainView : UserControl // windows) gets the bottom strip. private const double WideBreakpoint = 700; - public static readonly DirectProperty IsWideLayoutProperty = + // Orientation-driven fullscreen is mobile-only: on desktop a window that + // is wider than tall is just a window, not a rotated device. + private static readonly bool IsMobilePlatform = + OperatingSystem.IsAndroid() || OperatingSystem.IsIOS(); + + public static readonly DirectProperty ShowSidebarProperty = AvaloniaProperty.RegisterDirect( - nameof(IsWideLayout), o => o.IsWideLayout); + nameof(ShowSidebar), o => o.ShowSidebar); - public static readonly DirectProperty IsNarrowLayoutProperty = + public static readonly DirectProperty ShowBottomNavProperty = AvaloniaProperty.RegisterDirect( - nameof(IsNarrowLayout), o => o.IsNarrowLayout); + nameof(ShowBottomNav), o => o.ShowBottomNav); // Content inset differs by layout (desktop has more breathing room). Exposed // as a property because the single shared ContentControl can no longer pick @@ -29,41 +37,83 @@ public partial class MainView : UserControl private static readonly Thickness NarrowPadding = new(12); private bool _isWideLayout = true; + private bool _isFullscreen; + private bool _showSidebar = true; + private bool _showBottomNav; private Thickness _contentPadding = WidePadding; + private MainWindowViewModel? _vm; - public bool IsWideLayout - { - get => _isWideLayout; - private set - { - if (SetAndRaise(IsWideLayoutProperty, ref _isWideLayout, value)) - { - RaisePropertyChanged(IsNarrowLayoutProperty, !value, value == false); - ContentPadding = value ? WidePadding : NarrowPadding; - } - } - } - - public bool IsNarrowLayout => !_isWideLayout; - - public Thickness ContentPadding - { - get => _contentPadding; - private set => SetAndRaise(ContentPaddingProperty, ref _contentPadding, value); - } + public bool ShowSidebar => _showSidebar; + public bool ShowBottomNav => _showBottomNav; + public Thickness ContentPadding => _contentPadding; public MainView() { InitializeComponent(); + DataContextChanged += OnDataContextChanged; // Seed off the initial bounds — XAML evaluates IsVisible before // the first SizeChanged fires, so without this both layouts could // flash for a frame on narrow viewports. - IsWideLayout = Bounds.Width >= WideBreakpoint; + _isWideLayout = Bounds.Width >= WideBreakpoint; + UpdateChrome(); } protected override void OnSizeChanged(SizeChangedEventArgs e) { base.OnSizeChanged(e); - IsWideLayout = e.NewSize.Width >= WideBreakpoint; + _isWideLayout = e.NewSize.Width >= WideBreakpoint; + // Landscape on a phone = the user rotated the device. The VM decides + // whether that means fullscreen (only while a camera page is open). + if (IsMobilePlatform) + _vm?.SetViewportOrientation(e.NewSize.Width > e.NewSize.Height); + UpdateChrome(); + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (_vm is not null) + _vm.PropertyChanged -= OnVmPropertyChanged; + _vm = DataContext as MainWindowViewModel; + if (_vm is not null) + { + _vm.PropertyChanged += OnVmPropertyChanged; + // Push current orientation in case the device started in landscape + // and SizeChanged fired before the DataContext was assigned. + if (IsMobilePlatform && Bounds.Width > 0) + _vm.SetViewportOrientation(Bounds.Width > Bounds.Height); + } + SetFullscreen(_vm?.IsFullscreen ?? false); + } + + private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainWindowViewModel.IsFullscreen) && _vm is not null) + SetFullscreen(_vm.IsFullscreen); + } + + private void SetFullscreen(bool on) + { + if (_isFullscreen == on) + return; + _isFullscreen = on; + UpdateChrome(); + + // System status/navigation bars follow the app chrome on mobile; + // InsetsManager is null on desktop so this is a no-op there. + var insets = TopLevel.GetTopLevel(this)?.InsetsManager; + if (insets is not null) + insets.IsSystemBarVisible = !on; + } + + private void UpdateChrome() + { + var showSidebar = _isWideLayout && !_isFullscreen; + var showBottomNav = !_isWideLayout && !_isFullscreen; + var padding = _isFullscreen ? new Thickness(0) + : _isWideLayout ? WidePadding : NarrowPadding; + + SetAndRaise(ShowSidebarProperty, ref _showSidebar, showSidebar); + SetAndRaise(ShowBottomNavProperty, ref _showBottomNav, showBottomNav); + SetAndRaise(ContentPaddingProperty, ref _contentPadding, padding); } } diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index 1d0a551..4790683 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml @@ -9,8 +9,9 @@ - - + +