Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
110 changes: 110 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OpenIPC.Viewer.App.ViewModels"
xmlns:svc="using:OpenIPC.Viewer.App.Services"
x:Class="OpenIPC.Viewer.App.Controls.CameraPickerView"
x:DataType="vm:CameraPickerViewModel">

<!-- Camera filter: a button opening a searchable multi-select with counts,
followed by the ticked cameras as removable chips. -->
<WrapPanel Orientation="Horizontal" ItemSpacing="6" LineSpacing="6">
<Button AutomationProperties.AutomationId="CameraPicker"
Padding="10,6" CornerRadius="6" VerticalAlignment="Center"
Background="{StaticResource Bg2Brush}"
BorderBrush="{StaticResource BorderMediumBrush}" BorderThickness="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<Path Classes="lucide" Data="{StaticResource IconVideoCam}"
Stroke="{StaticResource TextSecondaryBrush}" Width="14" VerticalAlignment="Center" />
<TextBlock Text="{Binding ButtonLabel}" MaxWidth="180" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" />
<Path Classes="lucide" Data="{StaticResource IconChevronDown}"
Stroke="{StaticResource TextSecondaryBrush}" Width="10" VerticalAlignment="Center" />
</StackPanel>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="300" Spacing="8">
<TextBox Text="{Binding Search, Mode=TwoWay}"
Classes="clearButton"
VerticalContentAlignment="Center"
PlaceholderText="{Binding [Events.Cameras.Search], Source={x:Static svc:Localizer.Instance}}">
<TextBox.InnerLeftContent>
<Path Classes="lucide" Data="{StaticResource IconSearch}"
Stroke="{StaticResource TextTertiaryBrush}"
Width="14" Height="14" Margin="10,0,0,0" VerticalAlignment="Center" />
</TextBox.InnerLeftContent>
</TextBox>
<StackPanel Orientation="Horizontal" Spacing="6">
<RadioButton Classes="chip" GroupName="PickerScope"
IsChecked="{Binding IsWithItems, Mode=TwoWay}"
Content="{Binding WithItemsLabel}" />
<RadioButton Classes="chip" GroupName="PickerScope"
IsChecked="{Binding ShowAll, Mode=TwoWay}"
Content="{Binding [Events.Cameras.Everything], Source={x:Static svc:Localizer.Instance}}" />
</StackPanel>
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{Binding PickerItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:CameraPickItem">
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Padding="6,0,4,0" MinHeight="30">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Foreground="{StaticResource TextPrimaryBrush}" />
<Border Grid.Column="1" Height="4" CornerRadius="2" Margin="8,0"
Width="{Binding BarWidth}" VerticalAlignment="Center"
Background="{StaticResource AccentBrush}" Opacity="0.6" />
<TextBlock Grid.Column="2" Text="{Binding Count}" MinWidth="24"
TextAlignment="Right" VerticalAlignment="Center"
FontFamily="{StaticResource FontMono}"
FontSize="{StaticResource FontSizeSm}"
Foreground="{StaticResource TextSecondaryBrush}" />
</Grid>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid ColumnDefinitions="Auto,*">
<Button Classes="row-icon" Padding="6,4"
Command="{Binding ClearCommand}"
IsEnabled="{Binding HasSelection}"
Content="{Binding [Events.Cameras.Reset], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource AccentHoverBrush}" />
<TextBlock Grid.Column="1" Text="{Binding Footer}"
HorizontalAlignment="Right" VerticalAlignment="Center"
FontSize="{StaticResource FontSizeSm}"
Foreground="{StaticResource TextTertiaryBrush}" />
</Grid>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>

<ItemsControl ItemsSource="{Binding SelectedCameras}" VerticalAlignment="Center"
IsVisible="{Binding HasSelection}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" ItemSpacing="6" LineSpacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:CameraPickItem">
<Button Padding="10,3" CornerRadius="999"
Background="{StaticResource AccentTintBrush}"
BorderBrush="{StaticResource AccentBrush}" BorderThickness="1"
Command="{Binding $parent[ItemsControl].((vm:CameraPickerViewModel)DataContext).RemoveCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}" FontSize="{StaticResource FontSizeSm}"
Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" />
<Path Classes="lucide" Data="{StaticResource IconClose}"
Stroke="{StaticResource TextSecondaryBrush}" Width="10" Height="10" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</WrapPanel>
</UserControl>
11 changes: 11 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/CameraPickerView.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Avalonia.Controls;

namespace OpenIPC.Viewer.App.Controls;

public sealed partial class CameraPickerView : UserControl
{
public CameraPickerView()
{
InitializeComponent();
}
}
77 changes: 73 additions & 4 deletions src/OpenIPC.Viewer.App/Converters/LayoutTabConverters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,80 @@ public sealed class LayoutTabBrushConverter : IMultiValueConverter
public static readonly LayoutTabBrushConverter Instance = new();

public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
=> ThemeResource.Brush(LayoutTabIsActiveConverter.IsActive(values) ? "AccentBrush" : "Bg2Brush");
}

// Same inputs as LayoutTabBrushConverter; true only for the active tab (drives
// the tab's "⋯" actions button).
public sealed class LayoutTabIsActiveConverter : IMultiValueConverter
{
public static readonly LayoutTabIsActiveConverter Instance = new();

public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
=> IsActive(values);

internal static bool IsActive(IList<object?> values)
=> values.Count > 1 && values[0] is GridLayout self
&& values[1] is GridLayout act
&& self.Id == act.Id;
}

// Grid-size menu: value is the layout's current size, parameter the menu item's
// size ("1".."5"). The current size's glyph is drawn in the accent colour.
public sealed class GridSizeBrushConverter : IValueConverter
{
public static readonly GridSizeBrushConverter Instance = new();

public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var current = value is int n && parameter is string p
&& int.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size)
&& n == size;
return ThemeResource.Brush(current ? "AccentBrush" : "TextSecondaryBrush");
}

public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

// Grid size (1..5) → the matching IconGrid{n} geometry, so the size button
// shows the shape of the current grid.
public sealed class GridSizeIconConverter : IValueConverter
{
public static readonly GridSizeIconConverter Instance = new();

public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var n = value is int i ? Math.Clamp(i, 1, 5) : 2;
object? res = null;
return Application.Current?.Resources.TryGetResource($"IconGrid{n}", null, out res) == true ? res : null;
}

public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

// Resource key (e.g. "IconCar") → the theme resource, so view models can pick
// an icon without referencing Avalonia types.
public sealed class ResourceKeyConverter : IValueConverter
{
public static readonly ResourceKeyConverter Instance = new();

public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string key) return null;
object? res = null;
return Application.Current?.Resources.TryGetResource(key, null, out res) == true ? res : null;
}

public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

internal static class ThemeResource
{
public static IBrush Brush(string key)
{
var active = values.Count > 0 && values[0] is GridLayout self
&& values[1] is GridLayout act
&& self.Id == act.Id;
var key = active ? "AccentBrush" : "Bg2Brush";
object? res = null;
if (Application.Current?.Resources.TryGetResource(key, null, out res) == true && res is IBrush brush)
return brush;
Expand Down
17 changes: 14 additions & 3 deletions src/OpenIPC.Viewer.App/Messages/OpenCameraMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,21 @@ public sealed record GoBackToLibraryMessage;

// Phase 16: open the recordings player on a recorded segment, and return to
// the recordings list. CameraName travels with the message so the player can
// label itself without another directory lookup.
public sealed record OpenRecordingMessage(Recording Recording, string CameraName);
// label itself without another directory lookup. StartAt (offset into the
// file) lets the Events page open a recording at the moment of an event;
// Rate carries the player's speed over to the previous/next recording.
public sealed record OpenRecordingMessage(Recording Recording, string CameraName, TimeSpan? StartAt = null, double Rate = 1.0);

public sealed record GoBackToRecordingsMessage;
// Reload: the player changed the archive (deleted its recording), so the list
// must be re-read instead of shown from cache.
public sealed record GoBackToRecordingsMessage(bool Reload = false);

// Recording player asks for (or leaves) chrome-free fullscreen; the main window
// owns the state so it survives previous/next swapping the player VM.
public sealed record SetPlayerFullscreenMessage(bool On);

// AI page → Events filtered to AI detections.
public sealed record ShowDetectionEventsMessage;

public sealed record WindowMinimizedMessage;

Expand Down
Loading
Loading