-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainWindow.xaml
More file actions
555 lines (531 loc) · 41.8 KB
/
Copy pathMainWindow.xaml
File metadata and controls
555 lines (531 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
<Window x:Class="VrcPhotoManager.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:VrcPhotoManager"
xmlns:vm="clr-namespace:VrcPhotoManager.ViewModels"
xmlns:views="clr-namespace:VrcPhotoManager.Views"
mc:Ignorable="d"
Title="VRC Photo Manager" Height="900" Width="1400">
<Window.Resources>
<views:StatusToBrushConverter x:Key="StatusToBrushConverter" />
<views:RatingToBrushConverter x:Key="RatingToBrushConverter" />
<views:FaceCountToVisibilityConverter x:Key="FaceCountToVisibilityConverter" />
<views:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<views:ObjectNullToVisibilityConverter x:Key="ObjectNullToVisibilityConverter" />
<views:StringNotEmptyToBoolConverter x:Key="StringNotEmptyToBoolConverter" />
<views:RemoteStatusToVisibilityConverter x:Key="RemoteStatusToVisibilityConverter" />
<views:CropOverlayVisibilityConverter x:Key="CropOverlayVisibilityConverter" />
<views:CropOverlayMarginConverter x:Key="CropOverlayMarginConverter" />
<views:PendingCropLabelVisibilityConverter x:Key="PendingCropLabelVisibilityConverter" />
<views:EffectiveCropLabelConverter x:Key="EffectiveCropLabelConverter" />
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<!-- The always-1px frame drawn around the actual photo cell (fixed Width/Height below) -
constant regardless of selection, so the inner content area (and the crop-line
overlay computed from it) never shrinks or shifts. -->
<Style x:Key="PhotoItemStyle" TargetType="Border">
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="#333333" />
</Style>
<!-- Selection indicator: wraps PhotoItemStyle's fixed-size inner Border and grows its
OWN border outward from 0 into the cell's existing 4px Margin gap when selected,
instead of the inner border thickening inward - that was the earlier approach, and
it shrank the actual photo content area on selection (the whole image + the
crop-line overlay computed from it visibly shifted by a few pixels the instant you
selected a photo). BorderThickness and Margin always sum to 4 on each side, so this
never changes the cell's total footprint or nudges neighboring cells - only how much
of that fixed space is border vs. gap. A thick border instead of a checkbox overlay -
the checkbox competed for attention with the other corner badges and covered part of
the thumbnail itself, where a border wraps the whole cell without hiding anything.
Gold rather than reusing Cyan (the upload-status cloud) or LimeGreen (confirmed face
tags/the old Uploaded pill), so selection reads as its own distinct state at a
glance. -->
<Style x:Key="PhotoSelectionOuterStyle" TargetType="Border">
<Setter Property="Margin" Value="4" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BorderBrush" Value="Gold" />
<EventSetter Event="MouseEnter" Handler="PhotoItem_MouseEnter" />
<EventSetter Event="MouseLeave" Handler="PhotoItem_MouseLeave" />
<Style.Triggers>
<DataTrigger Binding="{Binding Selected}" Value="True">
<Setter Property="BorderThickness" Value="3" />
<Setter Property="Margin" Value="1" />
</DataTrigger>
<!-- Cyan instead of gold when selected AND either this photo's crop has a real
pending edit against what's actually live (PhotoViewModel.HasPendingCropEdit
- browsed/nudged since its last upload, not yet re-uploaded) or it still has
an old VRCDN copy pending removal (PhotoViewModel.PrepareForReupload, set
right before a re-upload starts) - a hint that uploading it will
replace/remove that old copy, not just add a fresh one. -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Selected}" Value="True" />
<Condition Binding="{Binding HasPendingCropEdit}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter Property="BorderBrush" Value="Cyan" />
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Selected}" Value="True" />
<Condition Binding="{Binding PendingRemovalRemoteId, Converter={StaticResource StringNotEmptyToBoolConverter}}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter Property="BorderBrush" Value="Cyan" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
<!-- A row's Items can hold a null entry - see PhotoRow's doc comment and
MainViewModel.RebuildRowsWithLeadingPadding. PhotoOrBlankTemplateSelector picks
BlankCellTemplate for those (an appropriately-sized empty cell, same footprint as
a real photo but with nothing in it) so the row still lays out correctly. -->
<DataTemplate x:Key="BlankCellTemplate">
<Border Width="{Binding DataContext.ThumbnailSize, RelativeSource={RelativeSource AncestorType=Window}}"
Height="{Binding DataContext.ThumbnailSize, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="4" />
</DataTemplate>
<DataTemplate x:Key="PhotoCellTemplate" DataType="{x:Type vm:PhotoViewModel}">
<Border x:Name="PhotoBorder" Style="{StaticResource PhotoSelectionOuterStyle}" Tag="{Binding}">
<Border.ContextMenu>
<!-- ContextMenu is a separate visual tree root, so DataContext doesn't
flow to it automatically - this is the standard WPF idiom to recover it. -->
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Set Rating">
<MenuItem Header="General" Command="{Binding SetRatingCommand}" CommandParameter="general" />
<MenuItem Header="Sensitive" Command="{Binding SetRatingCommand}" CommandParameter="sensitive" />
<MenuItem Header="Questionable" Command="{Binding SetRatingCommand}" CommandParameter="questionable" />
<MenuItem Header="Explicit" Command="{Binding SetRatingCommand}" CommandParameter="explicit" />
<Separator />
<MenuItem Header="Clear (unclassified)" Command="{Binding SetRatingCommand}" CommandParameter="{x:Null}" />
</MenuItem>
<MenuItem Header="View Metadata..." Click="ViewMetadata_Click" />
<MenuItem Header="Tag Faces..." Click="TagFaces_Click" />
<MenuItem Header="Copy VRCDN URL" Click="CopyVrcdnUrl_Click"
IsEnabled="{Binding RemoteUrl, Converter={StaticResource StringNotEmptyToBoolConverter}}" />
</ContextMenu>
</Border.ContextMenu>
<Border Style="{StaticResource PhotoItemStyle}"
Width="{Binding DataContext.ThumbnailSize, RelativeSource={RelativeSource AncestorType=Window}}"
Height="{Binding DataContext.ThumbnailSize, RelativeSource={RelativeSource AncestorType=Window}}">
<Grid x:Name="PhotoGrid">
<!-- Clicking anywhere on the thumbnail toggles selection (see
PhotoItemStyle's thick-gold-border DataTrigger for the visual state).
Middle-click opens Tag Faces directly - MouseUp (not MouseLeftButtonUp)
since WPF has no MouseMiddleButtonUp, so the handler itself filters by
ChangedButton to avoid double-handling the left-click case above. -->
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Cursor="Hand"
MouseLeftButtonUp="PhotoImage_Click"
MouseUp="PhotoImage_MiddleClick" />
<!-- Crop-line preview: for an already-Uploaded photo, shows the ratio it was
actually uploaded as (from UploadCropMode); for anything else, shows this
photo's own CropRatioOverride (set via [ / ] while hovering) if one's
set - see CropOverlayRatioResolver. No override, no crop lines: there's
no batch-wide default crop ratio anymore, only per-photo. Shown while
selected, or on hover for an already-Uploaded photo (see
CropOverlayVisibilityConverter). The Margin marks the same centered-crop
rectangle ThumbnailService.PrepareForUploadAsync applies at upload time -
see CropOverlayMarginConverter's doc comment for why it also has to
account for the Image's own Uniform-stretch letterboxing. Container size
comes from PhotoGrid's own ActualWidth (the fixed-size inner Border's
content area, unaffected by PhotoSelectionOuterStyle's outward-growing
selection border) rather than DataContext.ThumbnailSize. -->
<!-- No DropShadowEffect here (unlike the other badges) - its blur radiates
both sides of the line, softening/darkening a band of the photo just
inside the crop boundary and reading as the preview having shrunk by a
pixel or two. StrokeThickness="1" with SnapsToDevicePixels so the line
sits exactly on the crop boundary (no soft/inset edge from a centered
stroke straddling a fractional pixel). -->
<Rectangle Stroke="White" StrokeThickness="1" Fill="Transparent" IsHitTestVisible="False"
SnapsToDevicePixels="True" UseLayoutRounding="True">
<Rectangle.Margin>
<MultiBinding Converter="{StaticResource CropOverlayMarginConverter}">
<Binding Path="Thumbnail" />
<Binding Path="ActualWidth" ElementName="PhotoGrid" />
<Binding Path="RemoteStatus" />
<Binding Path="UploadCropMode" />
<Binding Path="CropRatioOverride" />
<Binding Path="CropOffsetX" />
<Binding Path="CropOffsetY" />
</MultiBinding>
</Rectangle.Margin>
<Rectangle.Visibility>
<MultiBinding Converter="{StaticResource CropOverlayVisibilityConverter}">
<Binding Path="Selected" />
<Binding Path="IsMouseOver" ElementName="PhotoBorder" />
<Binding Path="RemoteStatus" />
<Binding Path="UploadCropMode" />
<Binding Path="CropRatioOverride" />
<Binding Path="Thumbnail" />
</MultiBinding>
</Rectangle.Visibility>
</Rectangle>
<Border HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="4" Width="14" Height="14" CornerRadius="7"
BorderBrush="White" BorderThickness="1"
Background="{Binding Rating, Converter={StaticResource RatingToBrushConverter}}"
ToolTip="{Binding Rating}" />
<!-- Which Discord server this photo came from - null (no badge) for every
local-folder photo, or a Discord library whose guild has no custom icon.
Offset up from the plain Margin="4" corner (same trick AvatarType's label
uses at bottom-left) so it doesn't sit on top of the mutually-exclusive
cloud/crop/upload-status badges that already anchor there. -->
<Border HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="4,4,4,26" Width="14" Height="14" CornerRadius="7"
BorderBrush="White" BorderThickness="1"
Visibility="{Binding DiscordGuildIcon, Converter={StaticResource ObjectNullToVisibilityConverter}}"
ToolTip="From a Discord library">
<Border.Background>
<ImageBrush ImageSource="{Binding DiscordGuildIcon}" Stretch="UniformToFill" />
</Border.Background>
</Border>
<!-- Uploaded: a plain filled cyan cloud (no pill background) instead of a
text badge - NotUploaded is the common case and already shows nothing,
so a permanent "Uploaded" text pill on every uploaded photo was more
visual noise than the transient Uploading/Failed pills below actually
need. A vector Path (Material Design's "cloud" glyph geometry) rather
than a font character - Segoe MDL2 Assets' own cloud glyph (0xE753) is
outline-only with no filled variant, and a plain Unicode ☁ character
risks an OS emoji-font fallback rendering it as a full-color emoji that
ignores Fill entirely. A Path's Fill is unambiguous either way. -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="4"
Visibility="{Binding RemoteStatus, Converter={StaticResource RemoteStatusToVisibilityConverter}, ConverterParameter=Uploaded}">
<!-- The ratio/size it was actually uploaded as (e.g. "4:3 (Landscape)"),
not the crop currently selected in the dropdown above - those two can
differ once you've re-selected a different preset since the upload. -->
<Border Padding="4,1" CornerRadius="3" Background="#CC1B3A5E" Margin="0,0,4,0"
Visibility="{Binding UploadCropMode, Converter={StaticResource NullToVisibilityConverter}}"
ToolTip="{Binding UploadCropMode}">
<TextBlock Text="{Binding UploadCropModeShort}" FontSize="10" Foreground="White" />
</Border>
<Path Width="18" Height="18" Stretch="Uniform" Cursor="Hand"
Data="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
ToolTip="Uploaded to VRCDN - click to copy the URL"
MouseLeftButtonUp="CloudIcon_MouseLeftButtonUp">
<Path.Style>
<Style TargetType="Path">
<!-- Gray instead of the usual cyan when the actual VRCDN object
is a .png - either this app's own PNG upload option or an
older, non-JPEG upload matched by Sync VRCDN Metadata (see
PhotoViewModel.IsUploadedAsPng's doc comment). -->
<Setter Property="Fill" Value="Cyan" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsUploadedAsPng}" Value="True">
<Setter Property="Fill" Value="Gray" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
<Path.Effect>
<DropShadowEffect Color="Black" ShadowDepth="0" BlurRadius="3" Opacity="0.9" />
</Path.Effect>
</Path>
</StackPanel>
<!-- Same corner as the "Uploaded as" badge above (mutually exclusive by
RemoteStatus, so never both at once) - names whichever crop setting is
CURRENTLY in effect for a not-yet-uploaded photo: this photo's own
CropRatioOverride if [ / ] has set one, otherwise "Original (no crop)" -
there's no batch-wide default anymore, so no override really does mean
no crop. Needed because "no override" and "override explicitly set to
Original (no crop)" would otherwise look identical in the crop-line
overlay itself (no lines either way) - see
PendingCropLabelVisibilityConverter's doc comment. -->
<Border HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="4" Padding="4,1" CornerRadius="3" Background="#CC1B3A5E">
<Border.Visibility>
<MultiBinding Converter="{StaticResource PendingCropLabelVisibilityConverter}">
<Binding Path="Selected" />
<Binding Path="IsMouseOver" ElementName="PhotoBorder" />
<Binding Path="RemoteStatus" />
</MultiBinding>
</Border.Visibility>
<TextBlock Text="{Binding CropRatioOverride, Converter={StaticResource EffectiveCropLabelConverter}}"
FontSize="10" Foreground="White" />
</Border>
<Border HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="4" Padding="4,1" CornerRadius="3"
Background="{Binding RemoteStatus, Converter={StaticResource StatusToBrushConverter}}"
Visibility="{Binding RemoteStatus, Converter={StaticResource RemoteStatusToVisibilityConverter}, ConverterParameter='Uploading,Failed'}">
<TextBlock Text="{Binding RemoteStatus}" FontSize="10" Foreground="White" />
</Border>
<Border HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="4" Padding="4,1" CornerRadius="3"
Background="{Binding FaceCountBadgeBrush}"
Visibility="{Binding DetectedFaceCount, Converter={StaticResource FaceCountToVisibilityConverter}}"
ToolTip="Detected faces (tagged / total)">
<TextBlock Text="{Binding FaceCountDisplay}" FontSize="10" Foreground="White" />
</Border>
<!-- MaxWidth matches the same DataContext.ThumbnailSize binding used by the
outer photo Border's own Width/Height above, so a long/CJK avatar label
(Plan A labels are raw Booth listing titles, some ~53 characters of
Japanese text) can't render wider than the thumbnail and overlap
neighboring photos. TextTrimming + ToolTip keep the full name discoverable
on hover instead of just cutting it off silently. -->
<Border HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="4,4,4,20"
Padding="4,1" CornerRadius="3" Background="#CC1B3A5E"
MaxWidth="{Binding DataContext.ThumbnailSize, RelativeSource={RelativeSource AncestorType=Window}}"
ToolTip="{Binding AvatarType}"
Visibility="{Binding AvatarType, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Text="{Binding AvatarType}" FontSize="10" Foreground="White" TextTrimming="CharacterEllipsis" />
</Border>
<!-- People icon: VRCX embedded author/world/player metadata for this photo.
Single color for now - a second color tier for "everyone in the photo
has been identified" needs Phase 2+'s person-labeling data, which
doesn't exist yet. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Top"
Margin="4" Width="16" Height="16" CornerRadius="8"
Background="#CC000000"
Visibility="{Binding HasVrcxMetadata, Converter={StaticResource BoolToVisibilityConverter}}"
ToolTip="This photo has VRCX player metadata">
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Border>
</Border>
</DataTemplate>
<local:PhotoOrBlankTemplateSelector x:Key="PhotoOrBlankTemplateSelector"
PhotoTemplate="{StaticResource PhotoCellTemplate}"
BlankTemplate="{StaticResource BlankCellTemplate}" />
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Filters -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8" VerticalAlignment="Center">
<TextBlock Text="Rating:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Filter by the content rating you've assigned this photo. '(none)' means it hasn't been rated yet - Classify Photos can suggest one." />
<ComboBox ItemsSource="{Binding RatingFilterOptions}" SelectedItem="{Binding RatingFilter}" Width="110" Margin="0,0,16,0"
ToolTip="Filter by the content rating you've assigned this photo. '(none)' means it hasn't been rated yet - Classify Photos can suggest one." />
<TextBlock Text="Status:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Filter by VRCDN upload status." />
<ComboBox ItemsSource="{Binding StatusFilterOptions}" SelectedItem="{Binding StatusFilter}" Width="110" Margin="0,0,16,0"
ToolTip="Filter by VRCDN upload status." />
<TextBlock Text="Uploaded as:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Filter by which crop (if any) a photo was uploaded to VRCDN with." />
<ComboBox ItemsSource="{Binding UploadCropModeFilterOptions}" SelectedItem="{Binding UploadCropModeFilter}" Width="130" Margin="0,0,16,0"
ToolTip="Filter by which crop (if any) a photo was uploaded to VRCDN with." />
<TextBlock Text="Avatar:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Filter by detected avatar base. 'Unclassified' means Classify Avatars hasn't run on it yet; 'No confident match' means it ran but wasn't confident enough to guess." />
<!-- Real Plan A labels are raw Booth listing titles (some ~53 chars of Japanese
text) sharing long common prefixes, so entries are visually indistinguishable
at a narrow width even after widening - the ItemContainerStyle tooltip is what
actually makes each entry identifiable on hover. -->
<ComboBox ItemsSource="{Binding AvatarTypeFilterOptions}" DisplayMemberPath="DisplayText"
SelectedValuePath="Value" SelectedValue="{Binding AvatarTypeFilter}" Width="180" Margin="0,0,16,0"
ToolTip="Filter by detected avatar base. 'Unclassified' means Classify Avatars hasn't run on it yet; 'No confident match' means it ran but wasn't confident enough to guess.">
<ComboBox.ItemContainerStyle>
<Style TargetType="ComboBoxItem">
<Setter Property="ToolTip" Value="{Binding Value}" />
</Style>
</ComboBox.ItemContainerStyle>
</ComboBox>
<TextBlock Text="Player:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Search for a player to filter the photo list. Also matches old names and stylized usernames. '(tagged)' = you've confirmed a face tag for them. '(manual)' = added by name, not yet linked to a VRC account." />
<views:PlayerFilterPicker x:Name="PlayerFilterPicker" Width="170" Margin="0,0,8,0"
SelectedOption="{Binding SelectedPlayerFilter, Mode=TwoWay}"
SearchSource="{Binding}" />
<CheckBox Content="Tagged" IsChecked="{Binding TaggedOnlyFilter}" IsEnabled="{Binding CanFilterTaggedOnly}"
VerticalAlignment="Center" Margin="0,0,16,0"
ToolTip="Only show photos where you've confirmed a face tag for this person. Turn off to also include photos where VRCX just recorded them as present." />
<CheckBox Content="Own" IsChecked="{Binding OwnPhotosOnlyFilter}" IsEnabled="{Binding CanFilterOwnPhotosOnly}"
VerticalAlignment="Center" Margin="0,0,16,0"
ToolTip="Only show photos VRChat itself recorded you as the photographer for (via embedded PNG metadata). Excludes photos taken by someone else in the same instance, even if they ended up in your library." />
<TextBlock Text="Faces:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Show only photos with (or without) a detected face. Run Detect Faces first to populate this." />
<ComboBox ItemsSource="{Binding FaceCountFilterOptions}" SelectedItem="{Binding FaceCountFilter}" Width="70" Margin="0,0,16,0"
ToolTip="Show only photos with (or without) a detected face. Run Detect Faces first to populate this." />
<TextBlock Text="People:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Filter by how many people VRCX recorded in the instance when the photo was taken. This is VRCX's headcount, not the number of detected faces." />
<ComboBox ItemsSource="{Binding PlayerCountFilterOptions}" SelectedItem="{Binding PlayerCountFilter}" Width="130" Margin="0,0,16,0"
ToolTip="Filter by how many people VRCX recorded in the instance when the photo was taken. This is VRCX's headcount, not the number of detected faces." />
<TextBlock Text="Confidence:" VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip="Hide face-match suggestions below this confidence. All the way left = off (show everything). This isn't a percentage - most matches fall between about 0.10 and 0.22, so the slider is scaled to that range." />
<!-- Real stored confidence values are NOT 0-1: for the common case (2+ registered
people), it's CCIP's own match score margin over the runner-up candidate, which
only ever gets stored above its 0.10 acceptance floor (FaceMatcher.
DifferentialMarginThreshold) and rarely exceeds ~0.22 even for a clean match -
0.20 is already the AutoTagThreshold bar. A 0-1 (or the old CLIP-era 0-0.5)
slider made almost its entire range show zero results. -->
<Slider Minimum="0" Maximum="0.25" Value="{Binding MinSuggestionConfidence}" Width="120" VerticalAlignment="Center"
ToolTip="Hide face-match suggestions below this confidence. All the way left = off (show everything). This isn't a percentage - most matches fall between about 0.10 and 0.22, so the slider is scaled to that range." />
<TextBlock Text="{Binding MinSuggestionConfidenceLabel}" VerticalAlignment="Center" Width="32" Margin="4,0,16,0" />
</StackPanel>
<!-- Photo grid (left) + action buttons (right). -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Uses ListBox rather than a plain ItemsControl: plain ItemsControl does
NOT actually virtualize regardless of VirtualizingPanel/VirtualizingStackPanel
settings - real UI virtualization needs a Selector-derived container. Without
this, all ~4800 photos (each triggering its lazy thumbnail load) were being
fully realized on the UI thread at startup, freezing the window for many
seconds. Selection chrome is stripped via ItemContainerStyle since we use our
own CheckBox-based multi-select, not ListBox's built-in selection. -->
<ListBox Grid.Column="0" x:Name="PhotoGrid" ItemsSource="{Binding Rows}"
SizeChanged="PhotoGrid_SizeChanged"
ScrollViewer.CanContentScroll="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
BorderThickness="0" Background="Transparent">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Focusable" Value="False" />
<Setter Property="Padding" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:PhotoRow}">
<ItemsControl ItemsSource="{Binding Items}" ItemTemplateSelector="{StaticResource PhotoOrBlankTemplateSelector}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons - all the same width via a fixed-width vertical StackPanel
(Button's default HorizontalAlignment is Stretch, so each fills it). -->
<StackPanel Grid.Column="1" Orientation="Vertical" Width="170" Margin="8">
<Button Content="VRCDN Login" Command="{Binding LoginCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding LoginTooltip}">
<Button.Style>
<Style TargetType="Button">
<Style.Triggers>
<!-- Session expired - the only other sign is a status-bar message
that a later status update can immediately overwrite/hide. -->
<DataTrigger Binding="{Binding IsSessionExpired}" Value="True">
<Setter Property="Background" Value="IndianRed" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="Bold" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Separator Margin="0,0,0,8" />
<Button Content="Filter (Ctrl+F)" Click="FilterButton_Click" Padding="8,4" Margin="0,0,0,8"
ToolTip="Open a standalone filter panel - the same filters as the bar above, in one window you can keep open on the side." />
<Separator Margin="0,0,0,8" />
<Button Content="Scan Libraries" Command="{Binding ScanLibraryCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding ScanLibraryTooltip}" />
<Button Content="Cross-reference Gamelog" Command="{Binding CrossReferenceGamelogCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding CrossReferenceGamelogTooltip}" />
<Button Content="Sync VRC Players" Command="{Binding SyncVrcPlayerDataCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding SyncVrcPlayerDataTooltip}" />
<Separator Margin="0,0,0,8" />
<Button Content="Detect Faces" Command="{Binding ScanFacesCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding ScanFacesTooltip}" />
<Button Content="Suggest Faces" Command="{Binding SuggestFacesCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding SuggestFacesTooltip}" />
<Button Content="Classify Photos" Command="{Binding ClassifyPhotosCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding ClassifyPhotosTooltip}" />
<Button Content="Classify Avatars" Command="{Binding ClassifyAvatarsCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding ClassifyAvatarsTooltip}" />
<Separator Margin="0,0,0,8" />
<Button Content="Crop Print Borders" Command="{Binding CropPrintSelectedCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding CropPrintSelectedTooltip}" />
<Separator Margin="0,0,0,8" />
<!-- No batch-wide crop dropdown anymore - uploads at the original resolution
(uncropped) by default. Hover a photo and press [ / ] to cycle it through a
crop ratio individually (see README's Keyboard Shortcuts section and
Settings' Upload Crop Presets reference panel) before uploading it. -->
<Button Content="Upload Selected" Command="{Binding UploadSelectedCommand}" Padding="8,4" Margin="0,0,0,8" FontWeight="Bold"
ToolTip="{Binding UploadSelectedTooltip}" />
<Button Content="Sync VRCDN Metadata" Command="{Binding SyncMetadataCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding SyncMetadataTooltip}" />
<Button Content="Remove from VRCDN" Command="{Binding RemoveFromVrcdnCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding RemoveFromVrcdnTooltip}" />
<Button Command="{Binding DeselectAllCommand}" Padding="8,4" Margin="0,0,0,8"
Content="{Binding SelectedCount}" ContentStringFormat="Deselect {0} photos"
ToolTip="Clear the current photo selection - the thick gold border marks which photos are selected." />
<Button Content="Update VRCDN Index" Command="{Binding UpdateVrcdnIndexCommand}" Padding="8,4" Margin="0,0,0,8"
ToolTip="{Binding UpdateVrcdnIndexTooltip}" />
<Separator Margin="0,0,0,8" />
<Button Content="Settings" Click="SettingsButton_Click" Padding="8,4" Margin="0,0,0,8"
ToolTip="App preferences, including the WD14, CLIP, and avatar-classifier model folders." />
<Button Content="About" Click="AboutButton_Click" Padding="8,4" Margin="0,0,0,8"
ToolTip="About this app, and its VRChat/VRCDN disclaimer." />
<!-- Neither of these changes which photos show (Sort just reorders, Thumbnail
size just changes how big they render), so they live here instead of the
filter bar. -->
<Separator Margin="0,0,0,8" />
<TextBlock Text="Sort:" Margin="0,0,0,4" ToolTip="Order the photo grid." />
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SortOption}" Margin="0,0,0,8"
ToolTip="Order the photo grid." />
<TextBlock Text="Thumbnail size:" Margin="0,0,0,4" ToolTip="Size of each photo thumbnail in the grid." />
<Slider Minimum="80" Maximum="400" Value="{Binding ThumbnailSize}"
ToolTip="Size of each photo thumbnail in the grid." />
</StackPanel>
</Grid>
<!-- Status bar - QuotaDisplay docked right so it doesn't get pushed off-screen by a
long StatusMessage; null (not yet logged in / no successful fetch yet) collapses it
entirely instead of showing a misleading "0 / 0". -->
<DockPanel Grid.Row="2" Margin="8,4">
<TextBlock DockPanel.Dock="Right" Text="{Binding QuotaDisplay}" Margin="8,0,0,0"
Visibility="{Binding QuotaDisplay, Converter={StaticResource NullToVisibilityConverter}}"
ToolTip="VRCDN storage used / total. Refreshes after login and after each upload batch; a '~' prefix during an upload run means it's a local estimate (this batch's own upload sizes added as they complete), not yet confirmed by VRCDN." />
<TextBlock Text="{Binding StatusMessage}" />
</DockPanel>
<!-- Hover preview overlay. Used to be a Popup - a real separate OS window - anchored
beside the hovered thumbnail (Placement="Right") instead of at the cursor, to keep
the popup from covering the hovered item. That still flickered near the right/bottom
edges of the screen: WPF auto-repositions Popups that would go off-screen (flipping
or clamping), which can shift it right back over the hovered thumbnail and cursor.
Since a Popup is a separate window, the OS routes hit-testing to whichever window is
topmost at the cursor - once the popup covers it, the item underneath gets a
mouse-leave, closing the popup, which un-covers the cursor and reopens it -> a
flicker loop, regardless of the WPF-level IsHitTestVisible flag (that only affects
WPF's own software hit-testing, which the separate popup window bypasses).
An in-tree overlay isn't a separate window - there's only one real window, so
IsHitTestVisible="False" is genuinely effective (it's WPF's own layered hit-testing,
not OS window z-order) no matter where the overlay visually sits. Position is now
fixed (bottom-left) instead of following the hovered item, since correctness no
longer depends on avoiding the cursor. -->
<!-- RenderTransformOrigin="0,1" (bottom-left) matches the Border's own anchor
(HorizontalAlignment=Left, VerticalAlignment=Bottom), so the grow animation
expands outward from the same corner it's pinned to, instead of visibly shifting
position as it scales. -->
<Border x:Name="PreviewOverlay" Grid.Row="1" HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="16" Background="Black" BorderBrush="White" BorderThickness="1" Padding="4"
IsHitTestVisible="False" Visibility="Collapsed" RenderTransformOrigin="0,1">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="PreviewScaleTransform" ScaleX="1" ScaleY="1" />
<TranslateTransform x:Name="PreviewTranslateTransform" Y="0" />
</TransformGroup>
</Border.RenderTransform>
<StackPanel>
<Image x:Name="PreviewImage" MaxWidth="800" MaxHeight="800" Stretch="Uniform" />
<TextBlock x:Name="PreviewPlayers" Foreground="White" FontSize="11" Margin="4" TextWrapping="Wrap" MaxWidth="800" />
</StackPanel>
</Border>
<!-- Toast notification - MainViewModel.ToastRequested (see ShowToast in code-behind).
Opacity-only animation (no scale/translate like PreviewOverlay) since this is a
passive pop-up, not something the user interacts with while it's showing -
IsHitTestVisible=False throughout so it never blocks clicks on whatever's under it. -->
<Border x:Name="ToastOverlay" Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="16" Background="#EE1B3A5E" BorderBrush="Cyan" BorderThickness="1" CornerRadius="4"
Padding="12,6" IsHitTestVisible="False" Opacity="0">
<TextBlock x:Name="ToastText" Foreground="White" FontSize="12" TextWrapping="Wrap" MaxWidth="600" />
</Border>
</Grid>
</Window>