33using System . Collections . ObjectModel ;
44using System . IO ;
55using System . Linq ;
6+ using System . Reactive . Linq ;
67using System . Threading ;
78using System . Threading . Tasks ;
89using System . Globalization ;
1314using OpenIPC . Viewer . App . Messages ;
1415using OpenIPC . Viewer . App . Services ;
1516using OpenIPC . Viewer . App . ViewModels . Majestic ;
17+ using OpenIPC . Viewer . Core . Analytics ;
1618using OpenIPC . Viewer . Core . Entities ;
1719using OpenIPC . Viewer . Core . Majestic ;
1820using OpenIPC . Viewer . Core . Onvif ;
@@ -40,10 +42,19 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
4042 private readonly AudioMonitor _audio ;
4143 private readonly PushToTalkController _talk ;
4244 private readonly IReachabilityProbe _reachability ;
45+ private readonly IAnalyticsEngine _analytics ;
46+ private readonly AnalyticsBootstrap _analyticsBootstrap ;
4347 private readonly ILogger < SingleCameraPageViewModel > _logger ;
4448 private Camera _camera ;
4549 private DispatcherTimer ? _recTimer ;
4650
51+ private IDisposable ? _detectionsSub ;
52+ // True when WE attached this camera's frames to the analytics engine (the
53+ // fallback path — normally the grid tile's feed is still running and we
54+ // only mirror its results). Guards the detach so closing this page never
55+ // strips a live tile registration.
56+ private bool _analyticsAttachedHere ;
57+
4758 // On a stream fault, TCP-probe the camera so we can tell a wedged-but-alive
4859 // camera (Attention) from one that's truly gone (Offline). Short timeout.
4960 private static readonly TimeSpan ReachabilityProbeTimeout = TimeSpan . FromSeconds ( 2 ) ;
@@ -92,9 +103,23 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
92103 public string StatusHeading => Localizer . Instance [
93104 Status == CameraStatus . Attention ? "Stream.Interrupted" : "Stream.Disconnected" ] ;
94105
95- [ ObservableProperty ] private SessionTelemetry ? _telemetry ;
106+ [ ObservableProperty ]
107+ [ NotifyPropertyChangedFor ( nameof ( SourceAspect ) ) ]
108+ private SessionTelemetry ? _telemetry ;
96109 [ ObservableProperty ] private string ? _errorMessage ;
97110
111+ // AI detections (Phase 15) continue onto this page: boxes computed by the
112+ // engine (usually still fed by the grid tile's substream) are normalized
113+ // 0..1, so they land correctly on the mainstream picture here too.
114+ [ ObservableProperty ] private IReadOnlyList < Detection > _detections = Array . Empty < Detection > ( ) ;
115+
116+ public bool AnalyticsEnabled => _camera . AnalyticsOrDefault . Enabled ;
117+
118+ // Source frame aspect (width/height) so DetectionOverlay maps boxes into
119+ // the letterboxed video rect; 0 until telemetry → overlay uses full bounds.
120+ public double SourceAspect =>
121+ Telemetry is { Width : > 0 , Height : > 0 } t ? ( double ) t . Width / t . Height : 0 ;
122+
98123 // Visible while the session is mid-connect (or backing off a reconnect). Gated
99124 // on Session != null so the empty pre-activate window doesn't show a spinner
100125 // out of nowhere. State changes flip both flags via NotifyPropertyChangedFor.
@@ -215,6 +240,8 @@ public SingleCameraPageViewModel(
215240 AudioMonitor audio ,
216241 PushToTalkController talk ,
217242 IReachabilityProbe reachability ,
243+ IAnalyticsEngine analytics ,
244+ AnalyticsBootstrap analyticsBootstrap ,
218245 ILogger < SingleCameraPageViewModel > logger )
219246 {
220247 _camera = camera ;
@@ -231,8 +258,16 @@ public SingleCameraPageViewModel(
231258 _audio = audio ;
232259 _talk = talk ;
233260 _reachability = reachability ;
261+ _analytics = analytics ;
262+ _analyticsBootstrap = analyticsBootstrap ;
234263 _logger = logger ;
235264
265+ // Mirror this camera's detection results (whoever feeds the engine —
266+ // usually the still-running grid tile) onto the page overlay.
267+ _detectionsSub = _analytics . Results
268+ . Where ( r => r . CameraId == _camera . Id )
269+ . Subscribe ( r => Dispatcher . UIThread . Post ( ( ) => Detections = r . Detections ) ) ;
270+
236271 // Hydrate the shared monitor from the persisted prefs and reflect any
237272 // later change (incl. from another page) back into the speaker UI.
238273 _audio . Muted = _userSettings . Current . AudioMuted ;
@@ -633,6 +668,8 @@ public async Task ActivateAsync(CancellationToken ct)
633668 // only plays once unmuted; default is muted.
634669 if ( _audio . IsAvailable )
635670 _audio . Attach ( session , _camera . Id ) ;
671+
672+ AttachAnalyticsFallback ( session ) ;
636673 }
637674 catch ( Exception ex )
638675 {
@@ -815,6 +852,7 @@ private async Task ReloadStreamAsync()
815852 _stateSub ? . Dispose ( ) ;
816853 _telemetrySub ? . Dispose ( ) ;
817854 _audioPresenceSub ? . Dispose ( ) ;
855+ DetachAnalyticsFallback ( ) ;
818856 // Re-detect on the fresh session — a swapped camera may not have audio.
819857 HasAudio = false ;
820858 if ( Session is not null )
@@ -1300,6 +1338,33 @@ private async Task SaveSnapshotAsAsync()
13001338 }
13011339 }
13021340
1341+ // Fallback feed for the analytics engine: only when NOBODY is already
1342+ // feeding this camera (no grid tile — opened from the library, tile dropped
1343+ // by the session cap, stills mode, …). When a tile feed exists we only
1344+ // mirror its results; attaching over it would steal the registration and
1345+ // orphan the tile's detections when this page closes.
1346+ private void AttachAnalyticsFallback ( IVideoSession session )
1347+ {
1348+ if ( ! _camera . AnalyticsOrDefault . Enabled || _analytics . IsAttached ( _camera . Id ) )
1349+ return ;
1350+ _ = _analyticsBootstrap . EnsureStartedAsync ( ) ;
1351+ _analytics . Attach (
1352+ _camera . Id ,
1353+ session . Frames ,
1354+ ( ) => _camera . AnalyticsOrDefault ,
1355+ ( ) => ! _disposed && State == SessionState . Playing ) ;
1356+ _analyticsAttachedHere = true ;
1357+ }
1358+
1359+ private void DetachAnalyticsFallback ( )
1360+ {
1361+ if ( ! _analyticsAttachedHere )
1362+ return ;
1363+ _analyticsAttachedHere = false ;
1364+ _analytics . Detach ( _camera . Id ) ;
1365+ Detections = Array . Empty < Detection > ( ) ;
1366+ }
1367+
13031368 [ RelayCommand ]
13041369 private void Back ( ) =>
13051370 WeakReferenceMessenger . Default . Send ( new GoBackToLibraryMessage ( ) ) ;
@@ -1317,6 +1382,8 @@ public async ValueTask DisposeAsync()
13171382 _stateSub ? . Dispose ( ) ;
13181383 _telemetrySub ? . Dispose ( ) ;
13191384 _audioPresenceSub ? . Dispose ( ) ;
1385+ _detectionsSub ? . Dispose ( ) ;
1386+ DetachAnalyticsFallback ( ) ;
13201387 _recordings . StateChanged -= OnRecordingsStateChanged ;
13211388 _userSettings . Changed -= OnUserSettingsChanged ;
13221389 _coordinator . Invalidated -= OnCoordinatorInvalidated ;
0 commit comments