Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ Built with .NET 9 / 10 and Avalonia 12.
VideoToolbox / Android MediaCodec), auto-reconnect, auto SD/HD switching.
- **Multi-camera grid** — up to 25 streams, tabbed layouts, drag-reorder,
fullscreen kiosk mode, low-cost "stills" mode (periodic HTTP snapshots).
- **Single-camera view** — PTZ joystick + presets, telemetry overlay,
digital zoom (pinch / Ctrl+wheel), snapshot to disk + share.
- **Single-camera view** — PTZ joystick for sweeping plus a step keypad for
framing (one nudge per press, home position, move speed), presets,
telemetry overlay, digital zoom (pinch / Ctrl+wheel), snapshot to disk +
share. Which PTZ controls appear is read from the camera, so a device that
cannot step or go home is not offered buttons that would fail.
- **AI detection (local)** — ONNX object detection on-device (person / car /
animal…), boxes in the grid and single view, auto-record on detection.
No cloud: the model runs in-process.
Expand Down
16 changes: 16 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ private static LangCode DetectSystem()
["CameraPage.NightMode.Auto"] = "Auto",
["CameraPage.Preset.Placeholder"] = "preset name",
["CameraPage.Preset.Save"] = "+ Save",
["Ptz.Home"] = "Go to home position",
["Ptz.Stop"] = "Stop",
["Ptz.SetHome"] = "Set home here",
["Ptz.SetHome.Title"] = "Set home position",
["Ptz.SetHome.Message"] = "Make the camera's current position its home? The old home position is replaced.",
["Ptz.Speed"] = "Move speed",
["Ptz.ZoomIn"] = "Zoom in one step",
["Ptz.ZoomOut"] = "Zoom out one step",
["CameraPage.Snapshot"] = "Snapshot",
["CameraPage.Stop"] = "■ Stop",
["CameraPage.ApplyingStatus"] = "Applying…",
Expand Down Expand Up @@ -930,6 +938,14 @@ private static LangCode DetectSystem()
["CameraPage.NightMode.Auto"] = "Авто",
["CameraPage.Preset.Placeholder"] = "название пресета",
["CameraPage.Preset.Save"] = "+ Сохранить",
["Ptz.Home"] = "Перейти в домашнюю позицию",
["Ptz.Stop"] = "Стоп",
["Ptz.SetHome"] = "Сделать текущую позицию домашней",
["Ptz.SetHome.Title"] = "Задать домашнюю позицию",
["Ptz.SetHome.Message"] = "Сделать текущую позицию камеры домашней? Прежняя домашняя позиция будет заменена.",
["Ptz.Speed"] = "Скорость перемещения",
["Ptz.ZoomIn"] = "Приблизить на шаг",
["Ptz.ZoomOut"] = "Отдалить на шаг",
["CameraPage.Snapshot"] = "Снимок",
["CameraPage.Stop"] = "■ Стоп",
["CameraPage.ApplyingStatus"] = "Применение…",
Expand Down
129 changes: 129 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,37 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis

[ObservableProperty] private string? _snapshotPath;
[ObservableProperty] private PtzController? _ptz;

// What this camera's PTZ node can actually do, read once when the page
// opens. Null until then, and the buttons that depend on it stay hidden
// rather than failing when pressed.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SupportsHome))]
[NotifyPropertyChangedFor(nameof(SupportsSetHome))]
[NotifyPropertyChangedFor(nameof(CanStepPanTilt))]
[NotifyPropertyChangedFor(nameof(CanStepZoom))]
private PtzCapabilities? _ptzCapabilities;

// Move speed for steps, presets and home — the same 0..1 the joystick uses.
// 0.6 is brisk without overshooting on a fast dome.
[ObservableProperty] private double _ptzSpeed = 0.6;

public bool SupportsHome => PtzCapabilities?.SupportsHome ?? false;

public bool SupportsSetHome => PtzCapabilities?.SupportsSetHome ?? false;

// Until the capabilities have loaded the keys stay visible — that is the
// pre-capability behaviour, and hiding them for the split second of the
// probe would make the panel flicker.
public bool CanStepPanTilt => PtzCapabilities is not { } c || c.CanStepPanTilt;

public bool CanStepZoom => PtzCapabilities is not { } c || c.CanStepZoom;

// How far one press of an arrow moves, normalized. On a camera that reports
// its relative space in field-of-view units this is a sixth of the frame —
// small enough to frame a doorway, large enough that a press feels like it
// did something.
private const float PtzStepSize = 0.16f;
[ObservableProperty] private string _newPresetName = "";

// Majestic state. IsMajestic gates the whole config panel; MajesticConfig
Expand Down Expand Up @@ -575,6 +606,90 @@ private async Task ToggleRecordingAsync()
[RelayCommand]
private void TogglePtzOverlay() => IsPtzOverlayVisible = !IsPtzOverlayVisible;

// The keypad's middle key: stops whatever is moving, joystick sweep or a
// step whose camera ignored its timeout.
[RelayCommand]
private async Task StopPtzAsync()
{
if (Ptz is null) return;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await Ptz.StopAsync(cts.Token).ConfigureAwait(true);
}

// One nudge per press. The direction is a string so the eight arrows and
// the two zoom buttons are one command with a parameter in XAML rather than
// ten near-identical commands.
[RelayCommand]
private async Task StepAsync(string? direction)
{
if (Ptz is null || string.IsNullOrEmpty(direction)) return;

var step = direction switch
{
"up" => new PtzVelocity(0, PtzStepSize, 0),
"down" => new PtzVelocity(0, -PtzStepSize, 0),
"left" => new PtzVelocity(-PtzStepSize, 0, 0),
"right" => new PtzVelocity(PtzStepSize, 0, 0),
"upleft" => new PtzVelocity(-PtzStepSize, PtzStepSize, 0),
"upright" => new PtzVelocity(PtzStepSize, PtzStepSize, 0),
"downleft" => new PtzVelocity(-PtzStepSize, -PtzStepSize, 0),
"downright" => new PtzVelocity(PtzStepSize, -PtzStepSize, 0),
"zoomin" => new PtzVelocity(0, 0, PtzStepSize),
"zoomout" => new PtzVelocity(0, 0, -PtzStepSize),
_ => PtzVelocity.Zero,
};
if (step.Equals(PtzVelocity.Zero)) return;

try
{
await Ptz.StepAsync(step, (float)PtzSpeed, CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PTZ step {Direction} failed for {CameraId}", direction, _camera.Id);
}
}

[RelayCommand]
private async Task GoHomeAsync()
{
if (Ptz is null) return;
try
{
await Ptz.GoHomeAsync((float)PtzSpeed, CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
// Home is optional, and a camera may advertise PTZ and still refuse
// it. Log it rather than hiding the button on every camera because
// some cannot.
_logger.LogWarning(ex, "PTZ home failed for {CameraId}", _camera.Id);
}
}

// Overwriting the home position is not undoable from here, so it asks.
[RelayCommand]
private async Task SetHomeAsync()
{
if (Ptz is null) return;

var confirmed = await _dialogs.ConfirmAsync(
Localizer.Instance["Ptz.SetHome.Title"],
Localizer.Instance["Ptz.SetHome.Message"],
confirmLabel: Localizer.Instance["Ptz.SetHome"],
cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true);
if (!confirmed) return;

try
{
await Ptz.SetHomeAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PTZ set-home failed for {CameraId}", _camera.Id);
}
}

[RelayCommand]
private void ResetZoom() => ZoomLevel = MinZoom;

Expand Down Expand Up @@ -1191,9 +1306,23 @@ private async Task InitPtzAsync(CameraCredentials? creds, CancellationToken ct)
var port = _camera.OnvifPort ?? 80;
var endpoint = OnvifEndpoint.FromHost(_camera.Host, port, creds);
Ptz = new PtzController(_onvif, endpoint, _camera.OnvifProfileToken!);
// The capability probe is several sequential SOAP calls; a camera that
// half-answers must not stall the presets (or the Majestic setup queued
// behind this), so it runs in the background. Until it lands the pad
// shows every key, as it did before capabilities existed.
_ = LoadPtzCapabilitiesAsync(Ptz, ct);
await ReloadPresetsAsync(ct).ConfigureAwait(true);
}

// Best-effort: a camera that cannot describe itself gets the continuous-only
// profile (GetCapabilitiesAsync never throws).
private async Task LoadPtzCapabilitiesAsync(PtzController ptz, CancellationToken ct)
{
var caps = await ptz.GetCapabilitiesAsync(ct).ConfigureAwait(true);
if (ReferenceEquals(Ptz, ptz))
PtzCapabilities = caps;
}

private async Task ReloadPresetsAsync(CancellationToken ct)
{
if (Ptz is null) return;
Expand Down
87 changes: 87 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@
x:Class="OpenIPC.Viewer.App.Views.Pages.SingleCameraPage"
x:DataType="vm:SingleCameraPageViewModel">

<UserControl.Styles>
<!-- PTZ step keys: square and quiet until hovered, so nine of them read as
one keypad rather than nine competing buttons. -->
<Style Selector="Button.ptz-step">
<Setter Property="Width" Value="42" />
<Setter Property="Height" Value="34" />
<Setter Property="Margin" Value="2" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="#33FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.ptz-step:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="#101010" />
</Style>
<Style Selector="Button.ptz-home">
<Setter Property="Background" Value="#4DFFFFFF" />
</Style>
</UserControl.Styles>

<!-- PageScroll: Disabled by default so on desktop/wide the Grid fills the
viewport and the video row (*) stretches — identical to a plain Grid.
On phone-width viewports the code-behind switches the video row to a
Expand Down Expand Up @@ -133,6 +157,69 @@
IsVisible="{Binding IsPtzPanelVisible}">
<controls:PtzJoystick Controller="{Binding Ptz}" HorizontalAlignment="Right" />

<!-- Step pad. The joystick above is for sweeping a scene; this is for
framing one — a fixed nudge per press, which is the only way to
land on a doorway at full zoom. Stop sits in the middle; home
and "set home" share the row below when the camera has one. -->
<Border Background="#90000000" CornerRadius="6" Padding="8" HorizontalAlignment="Right">
<StackPanel Spacing="8">
<!-- Explicit cells rather than a UniformGrid: a UniformGrid skips
hidden children, so any hidden key would slide the rest of
the arrows into the wrong places. -->
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,Auto,Auto"
IsVisible="{Binding CanStepPanTilt}">
<Button Grid.Row="0" Grid.Column="0" Classes="ptz-step" Content="↖" CommandParameter="upleft"
Command="{Binding StepCommand}" />
<Button Grid.Row="0" Grid.Column="1" Classes="ptz-step" Content="↑" CommandParameter="up"
Command="{Binding StepCommand}" />
<Button Grid.Row="0" Grid.Column="2" Classes="ptz-step" Content="↗" CommandParameter="upright"
Command="{Binding StepCommand}" />
<Button Grid.Row="1" Grid.Column="0" Classes="ptz-step" Content="←" CommandParameter="left"
Command="{Binding StepCommand}" />
<!-- Stop in the middle whatever the camera supports, same as the
web pad: the escape hatch for a move that did not stop. -->
<Button Grid.Row="1" Grid.Column="1" Classes="ptz-step ptz-home" Content="■" FontSize="12"
Command="{Binding StopPtzCommand}"
ToolTip.Tip="{Binding [Ptz.Stop], Source={x:Static svc:Localizer.Instance}}" />
<Button Grid.Row="1" Grid.Column="2" Classes="ptz-step" Content="→" CommandParameter="right"
Command="{Binding StepCommand}" />
<Button Grid.Row="2" Grid.Column="0" Classes="ptz-step" Content="↙" CommandParameter="downleft"
Command="{Binding StepCommand}" />
<Button Grid.Row="2" Grid.Column="1" Classes="ptz-step" Content="↓" CommandParameter="down"
Command="{Binding StepCommand}" />
<Button Grid.Row="2" Grid.Column="2" Classes="ptz-step" Content="↘" CommandParameter="downright"
Command="{Binding StepCommand}" />
</Grid>

<Grid ColumnDefinitions="*,*" IsVisible="{Binding CanStepZoom}">
<Button Grid.Column="0" Classes="ptz-step" Content="−" CommandParameter="zoomout"
Command="{Binding StepCommand}" HorizontalAlignment="Stretch"
ToolTip.Tip="{Binding [Ptz.ZoomOut], Source={x:Static svc:Localizer.Instance}}" />
<Button Grid.Column="1" Classes="ptz-step" Content="+" CommandParameter="zoomin"
Command="{Binding StepCommand}" HorizontalAlignment="Stretch"
ToolTip.Tip="{Binding [Ptz.ZoomIn], Source={x:Static svc:Localizer.Instance}}" />
</Grid>

<StackPanel Spacing="2">
<TextBlock Text="{Binding [Ptz.Speed], Source={x:Static svc:Localizer.Instance}}"
FontSize="{StaticResource FontSizeXs}" Foreground="#c8c8c8" />
<Slider Minimum="0.1" Maximum="1.0" Value="{Binding PtzSpeed, Mode=TwoWay}" Width="132" />
</StackPanel>

<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding SupportsHome}">
<Button Grid.Column="0" Classes="ptz-step" Content="⌂"
Command="{Binding GoHomeCommand}"
ToolTip.Tip="{Binding [Ptz.Home], Source={x:Static svc:Localizer.Instance}}" />
<Button Grid.Column="1" Classes="outlined"
Content="{Binding [Ptz.SetHome], Source={x:Static svc:Localizer.Instance}}"
Command="{Binding SetHomeCommand}"
IsVisible="{Binding SupportsSetHome}"
FontSize="{StaticResource FontSizeXs}"
Margin="2" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
</Grid>
</StackPanel>
</Border>

<Border Background="#90000000" CornerRadius="6" Padding="8" MaxWidth="280">
<StackPanel Spacing="6">
<ItemsControl ItemsSource="{Binding Presets}">
Expand Down
19 changes: 19 additions & 0 deletions src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,23 @@ public interface IOnvifClient
Task GotoPresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct);
Task<string> SetPresetAsync(OnvifEndpoint endpoint, string profileToken, string name, CancellationToken ct);
Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct);

// Read once per camera: which move modes this node implements and the
// ranges it declares. Sending a value outside a declared range is the usual
// reason a move is silently clamped or refused.
Task<PtzCapabilities> GetPtzCapabilitiesAsync(
OnvifEndpoint endpoint, string profileToken, CancellationToken ct);

// A step, in normalized [-1, 1] per axis, relative to where the camera is
// now — what the arrow buttons send.
Task RelativeMoveAsync(
OnvifEndpoint endpoint, string profileToken, PtzVelocity step, float speed, CancellationToken ct);

// Where the camera is pointing, and whether it is still moving.
Task<PtzStatus> GetPtzStatusAsync(
OnvifEndpoint endpoint, string profileToken, CancellationToken ct);

Task GotoHomeAsync(OnvifEndpoint endpoint, string profileToken, float speed, CancellationToken ct);

Task SetHomeAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct);
}
Loading
Loading