TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.
One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.
The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.
The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).
The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.
Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
This commit is contained in:
+37
-22
@@ -238,18 +238,31 @@
|
||||
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
|
||||
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Margin="8,0,0,0" Click="ShapeButton_Click"
|
||||
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- TASK 59: the shape is a CHOICE (Rect | Circle) under a
|
||||
"Shape" title, with Rect selected by default — the circle
|
||||
is not offered in the vertical Chat layout, so that option
|
||||
is not even shown there (CanToggleClipShape). -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Shape" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||
<TextBlock Text="Rect" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}"
|
||||
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<!-- The drag hint sits under the shape row — indented, so the
|
||||
parentage shows. -->
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="10,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||
@@ -259,8 +272,8 @@
|
||||
above) so the webcam's border/background section stays the
|
||||
one panel bound to SelectedElement.IsWebcam. -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
@@ -268,7 +281,7 @@
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
@@ -280,8 +293,8 @@
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
||||
ToolTip="Pick color"/>
|
||||
@@ -289,8 +302,8 @@
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
MaxLength="7" ToolTip="#RRGGBB"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="WebcamBorderAccept_Click">
|
||||
@@ -311,9 +324,11 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- TASK 51: background effects — two pills acting as radio buttons
|
||||
(one on turns the other off; both off = no effect). -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,6,0,6"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
(one on turns the other off; both off = no effect). TASK 59:
|
||||
the pills are indented under the Background title, and the
|
||||
strength row one level deeper, under Blur. -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
|
||||
@@ -321,7 +336,7 @@
|
||||
ToolTip="Remove the background behind you — the layers below show through"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
|
||||
@@ -329,7 +344,7 @@
|
||||
ToolTip="Blur the background behind you"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,6"
|
||||
<DockPanel Margin="20,0,0,6"
|
||||
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
|
||||
|
||||
@@ -94,12 +94,6 @@ public partial class LeftPanel : UserControl
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
|
||||
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs` |
|
||||
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur) |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
+19
-4
@@ -25,6 +25,15 @@
|
||||
panel. Now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox` (new), properties fields →
|
||||
`YtTextBoxCompact` (new, BasedOn `YtTextBox`). Guard:
|
||||
`ytLive.Tests/PropertiesStyleTests.cs`.
|
||||
- **TASK 59 — the properties panel is a hierarchy** (shipped 2026-10-21). A title owns its
|
||||
content and the content is indented one level (10px): `Shape` (new title) over the
|
||||
Mirror switch and the shape choice, the drag hint indented under that row, Geometry /
|
||||
Border Color / Border Thickness as titles with their fields indented, Background's
|
||||
Remove + Blur indented, and Strength one level deeper under Blur. **The shape became a
|
||||
CHOICE**: `Rect` | `Circle` as two segment options with **Rect selected by default**
|
||||
(the old single button showed the *next* action, "Round"). The mutual exclusion lives
|
||||
in the model (`IsRectShape`/`IsRoundShape` on `SceneElement`) — a WPF `RadioButton`
|
||||
group would kill the bindings. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`.
|
||||
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
|
||||
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
|
||||
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
|
||||
@@ -160,10 +169,15 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
the full top area and the circle option is not offered. **PASSES.**
|
||||
|
||||
## Next step
|
||||
- **The creator's remaining ask (not started):** the webcam **Border Color** drop-down should show the
|
||||
**colour NAME next to the hex** (e.g. `#e94560 Crimson`) — the hex stays, the name is the addition.
|
||||
Plan: a testable hex→name catalogue (Models/Helpers) consumed by the code-behind
|
||||
`WebcamBorderColorPicker_Click` in `Controls/LeftPanel.xaml.cs`; the tooltip becomes "Name (#hex)".
|
||||
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
|
||||
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
|
||||
the Shape button toggles circle/rect there, open the **Chat** scene and confirm the Shape button is
|
||||
**gone** (rect only) and that dragging the tile up and enlarging it fills a whole 607×540 area, then
|
||||
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
|
||||
into a circle there, open the **Chat** scene and confirm the **Circle** option is **gone** (rect only)
|
||||
and that dragging the tile up and enlarging it fills a whole 607×540 area, then
|
||||
switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
|
||||
eyeball.
|
||||
- Nothing else is queued for llamacasty — the creator queues work.
|
||||
@@ -193,8 +207,9 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
- TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
|
||||
|
||||
## Tests
|
||||
- Last full run: **431/431 passed, 0 warnings** (2026-10-21, this session — after TASK 58's
|
||||
`PropertiesStyleTests`). The previous run was 429/430: the one failure was
|
||||
- Last full run: **432/432 passed, 0 warnings** (2026-10-21, this session — after TASK 59's
|
||||
`PropertiesHierarchyTests`; the run before it was 431/431 after TASK 58's
|
||||
`PropertiesStyleTests`). The one failure seen earlier in the session was
|
||||
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
|
||||
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
|
||||
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
|
||||
|
||||
+30
-2
@@ -120,7 +120,36 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
set
|
||||
{
|
||||
if (Set(ref _clipShape, value))
|
||||
Raise(nameof(ShapeButtonText));
|
||||
{
|
||||
Raise(nameof(IsRectShape));
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is the plain rectangle — the shape a
|
||||
/// camera has when none was ever picked (TASK 59: the properties panel shows
|
||||
/// the shape as a CHOICE, and Rect is the selected default).</summary>
|
||||
public bool IsRectShape
|
||||
{
|
||||
get => ClipShape != ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Round -> rect (restores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRectShape));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
|
||||
public bool IsRoundShape
|
||||
{
|
||||
get => ClipShape == ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Rect -> round (stores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +199,6 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
|
||||
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||
|
||||
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||
private WriteableBitmap? _videoImageSource;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
|
||||
@@ -2100,3 +2100,45 @@ wearing light-system chrome. Unstyled `CheckBox` is the same story.
|
||||
|
||||
**Side effect worth naming:** the sweep also exposed a typo'd foreground (`#e0d0e0` instead of
|
||||
`#e0e0e0`) that only a single source of truth can prevent from recurring.
|
||||
|
||||
## 2026-10-21 — A CHOICE IN WPF: MUTUAL EXCLUSION BELONGS IN THE MODEL, NOT IN A RADIOBUTTON GROUP (RECIPE — TASK 59)
|
||||
|
||||
A "pick one of these" control (Rect | Circle) bound in WPF has two classic traps, both
|
||||
of which break the panel silently — the control keeps showing what it last showed while
|
||||
the model says something else.
|
||||
|
||||
**Trap 1 — the RadioButton group kills the binding.** A `RadioButton` group unchecks its
|
||||
siblings by setting `IsChecked` **in code** (`RadioButton.UncheckRadioButton` →
|
||||
`SetCurrentValueInternal`). WPF sees the target property being set outside the binding
|
||||
and **drops the binding** — the options stop reflecting the model for good
|
||||
("Losing the binding for radio button after I set the property manually in code",
|
||||
stackoverflow 7376373; the same trap in a template, 57275046).
|
||||
|
||||
**Trap 2 — a ToggleButton unchecks itself when clicked while checked.** A plain
|
||||
`ToggleButton` (unlike `RadioButton`) toggles `IsChecked` off when you click it while it
|
||||
is on. With a TwoWay binding the model receives `false` — and if the model refuses that
|
||||
write, the UI must be told again, or the option stays visibly unchecked while the model
|
||||
holds the truth.
|
||||
|
||||
**The recipe:**
|
||||
1. **Give the model one bool per option** (`IsRectShape`, `IsRoundShape`) — each
|
||||
settable, each doing the real work through the existing `Toggle…()` path so the
|
||||
side-effects (store/restore the pre-round rect) are not duplicated.
|
||||
2. **The setter ALWAYS re-raises** its own property name (`Raise(nameof(IsRectShape))`),
|
||||
even when the write changed nothing. The binding re-reads the getter and the UI snaps
|
||||
back to the model's truth — that is what makes a refused write (a locked shape)
|
||||
visible instead of sticky.
|
||||
3. **Raise BOTH option properties whenever the underlying value changes** (in the
|
||||
`ClipShape` setter), so the pair can never disagree on screen.
|
||||
4. **Do NOT use a `RadioButton` `GroupName`** for a bound pair. Two plain
|
||||
`ToggleButton`s (styled as segments) with model-side mutual exclusion keep their
|
||||
bindings alive (stackoverflow 50980687 measures the same behaviour with
|
||||
`IsChecked` bound to separate bools).
|
||||
5. **Show the CURRENT state, not the next action.** A button labelled "Round" when the
|
||||
camera is a rectangle tells the user what will happen; a selected "Rect" option tells
|
||||
them what IS. The creator called this out: *"Rect should be selected as a default if
|
||||
none exists."*
|
||||
|
||||
**Bonus:** the same "title owns its content, content is indented one level" rule applies
|
||||
to any dense settings panel — one indent level per ownership level, two when a title is
|
||||
itself nested (Strength under Blur under Background).
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
| 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) |
|
||||
| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | ✅ **Both units shipped 2026-10-21** (Unit 1 = Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly. Unit 2 = Chat scene: rect only — the circle is not offered — the tile or one whole 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
|
||||
| 58 | **Properties-panel style consistency (the theme owns the look)** | ✅ Done — 2026-10-21 (the creator's "why is the drop-down not in the same style as the others?" — the drop-downs/fields/check boxes had been hand-painted instead of themed, so WPF drew the light system template; now every one carries a theme style, guarded by a test) | [`TASKS/task-58-properties-style-consistency.md`](TASKS/task-58-properties-style-consistency.md) |
|
||||
| 59 | **The properties panel is a hierarchy (titles own their content)** | ✅ Done — 2026-10-21 (the creator's ruling: a "Shape" title above the Mirror/Rect row, the drag hint indented under it, the same for Geometry / Border Color / Border Thickness, Remove + Blur under Background, Strength under Blur — and the shape became a CHOICE: Rect | Circle with Rect selected by default) | [`TASKS/task-59-properties-hierarchy.md`](TASKS/task-59-properties-hierarchy.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# TASK 59 — The properties panel is a hierarchy (titles own their content)
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator's ruling (2026-10-21), on the Live scene → Layers → webcam → Properties:
|
||||
|
||||
> Mirror Rect: needs a title "Shape" above it and **Rect should be selected as a default
|
||||
> if none exists** … also, the "drag to move" help text needs to be **indented under
|
||||
> "Mirror Rect" to show parentage** … same with **Geometry, Border Color, Border
|
||||
> Thickness**. Background → Remove: Remove should be indented under Background as should
|
||||
> Blur. Blur → Strength: Strength should be indented under Blur.
|
||||
|
||||
One rule covers all of it: **a title owns its content, and the content is indented one
|
||||
level (10px) under it.** Two levels deep when a title is itself nested (Strength under
|
||||
Blur under Background).
|
||||
|
||||
## The panel now reads
|
||||
```
|
||||
Shape ← NEW title
|
||||
[Mirror] [Rect] [Circle] ← the shape is a CHOICE, Rect selected by default
|
||||
Drag to move · drag the corner dot to resize
|
||||
Geometry ← title (already there), fields indented
|
||||
X [ ] Y [ ]
|
||||
W [ ] H [ ]
|
||||
Border Color ← promoted to a title, the field indented
|
||||
[#2c3a5c] [..]
|
||||
Border Thickness ← promoted to a title, the slider indented
|
||||
[slider] 3
|
||||
Background ← title, its rows indented
|
||||
Remove [pill]
|
||||
Blur [pill]
|
||||
Strength [slider] 50% ← one level deeper, under Blur
|
||||
```
|
||||
|
||||
## The shape: a choice, not a toggle
|
||||
Before: one button whose content was `ShapeButtonText` — the **next** action ("Round"
|
||||
when the camera was a rectangle). The creator could not tell what the camera *was*, only
|
||||
what it would become.
|
||||
|
||||
Now: two `SegmentToggle` options — **Rect** and **Circle** — bound to two new settable
|
||||
bools on `SceneElement`:
|
||||
- `IsRectShape` — true when the clip is the plain rectangle, which is what a camera is
|
||||
when no shape was ever picked (**the creator's "Rect is selected by default if none
|
||||
exists"**).
|
||||
- `IsRoundShape` — true when the clip is a circle.
|
||||
|
||||
Each setter flips the shape through `ToggleClipShape()` (so the pre-round rect is still
|
||||
stored/restored) and **always re-raises** — a refused write (the vertical Chat layout is
|
||||
rect-only, TASK 57) makes the UI snap back to the truth instead of sticking.
|
||||
|
||||
`ShapeButtonText` is gone (dead code after this change).
|
||||
|
||||
### Why the mutual exclusion lives in the model
|
||||
Not in a WPF `RadioButton` group: a RadioButton group unchecks its siblings by setting
|
||||
`IsChecked` **in code**, which makes WPF drop the binding ("Losing the binding for radio
|
||||
button after I set the property manually in code", stackoverflow 7376373) — the panel
|
||||
would silently stop reflecting the model. Two bool properties with model-side setters keep
|
||||
the binding alive (stackoverflow 50980687 / 57275046 describe the same trap).
|
||||
|
||||
## The circle option is still not offered where it must not be
|
||||
The Circle option's `Visibility` binds to `CanToggleClipShape` — false in the vertical
|
||||
tier's Chat layout (TASK 57) — so the option is not even shown there, and the model
|
||||
refuses the write anyway.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/PropertiesHierarchyTests.cs` — the ONE test for this change:
|
||||
- the model: Rect is selected by default → picking Circle makes the clip Round → picking
|
||||
Rect again restores it → the vertical Chat lock still refuses the circle;
|
||||
- the panel: the `Shape` title owns both options, the Circle option is the one gated by
|
||||
`CanToggleClipShape`, the drag hint is indented and follows the shape row, every titled
|
||||
block's content is indented 10px, and Strength sits at 20px (under Blur).
|
||||
|
||||
432/432 green at ship time.
|
||||
@@ -277,6 +277,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
|
||||
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
|
||||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back
|
||||
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
|
||||
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 59 — the creator's properties-panel hierarchy (2026-10-21):
|
||||
/// "Mirror Rect: needs a title 'Shape' above it and Rect should be selected as a default
|
||||
/// if none exists … the 'drag to move' help text needs to be indented under 'Mirror Rect'
|
||||
/// to show parentage … same with Geometry, Border Color, Border Thickness … Remove should
|
||||
/// be indented under Background as should Blur … Strength should be indented under Blur."
|
||||
///
|
||||
/// <para>One test, the whole story: the shape is a CHOICE and Rect is the selected
|
||||
/// default (the model), the options live under a "Shape" title with the drag hint
|
||||
/// indented beneath them, every titled block owns its content one indent deeper, and the
|
||||
/// strength row sits under Blur — and the TASK 57 rule still holds: the circle option is
|
||||
/// not offered in the vertical Chat layout, and the model refuses it there.</para></summary>
|
||||
public class PropertiesHierarchyTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_webcam_properties_show_a_Shape_title_with_Rect_selected_and_indent_their_content()
|
||||
{
|
||||
// ── The model: the shape is a choice, and Rect is the default ─────────
|
||||
var camera = new WebcamSceneConfig { WebcamId = "webcam-1", Name = "Webcam" };
|
||||
Assert.True(camera.IsRectShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
|
||||
camera.IsRoundShape = true;
|
||||
Assert.Equal(ClipShape.Round, camera.ClipShape);
|
||||
Assert.False(camera.IsRectShape);
|
||||
Assert.True(camera.IsRoundShape);
|
||||
|
||||
camera.IsRectShape = true;
|
||||
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
||||
Assert.True(camera.IsRectShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
|
||||
// The vertical Chat layout locks the shape to a rect — the circle option is
|
||||
// not offered there, and the model refuses it even if the UI asks.
|
||||
camera.VerticalShapeLocked = true;
|
||||
camera.IsRoundShape = true;
|
||||
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
Assert.True(camera.IsRectShape);
|
||||
|
||||
// ── The panel: a Shape title owns the options, the hint is indented ───
|
||||
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
|
||||
var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == "Shape");
|
||||
Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
|
||||
|
||||
var rectOption = OptionBoundTo(doc, "IsRectShape");
|
||||
var circleOption = OptionBoundTo(doc, "IsRoundShape");
|
||||
Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
|
||||
|
||||
// The CIRCLE is the option that is not offered in the vertical Chat layout.
|
||||
Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
|
||||
|
||||
// Both options live under the Shape title.
|
||||
var shapeBlock = shapeTitle.Parent!;
|
||||
Assert.Contains(rectOption, shapeBlock.Descendants());
|
||||
Assert.Contains(circleOption, shapeBlock.Descendants());
|
||||
|
||||
// The drag hint is indented, and it comes after the shape row — it reads as
|
||||
// part of the shape block.
|
||||
var hint = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& ((string?)e.Attribute("Text") ?? "").StartsWith("Drag to move"));
|
||||
Assert.True(LeftIndent(hint) > 0, "the drag hint is not indented under the shape row");
|
||||
var siblings = shapeBlock.Parent!.Elements().ToList();
|
||||
Assert.True(siblings.IndexOf(shapeBlock) < siblings.IndexOf(hint),
|
||||
"the drag hint comes before the shape row");
|
||||
|
||||
// ── Every titled block owns its content one indent deeper ────────────
|
||||
var geometryTitle = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
||||
Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
|
||||
foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
|
||||
Assert.Equal(10, LeftIndent(row));
|
||||
|
||||
// The webcam block (the one the TASK 51 test finds by its binding).
|
||||
var webcamPanel = doc.Descendants().Single(e => e.Name.LocalName == "StackPanel"
|
||||
&& ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam"));
|
||||
|
||||
foreach (var title in new[] { "Border Color", "Border Thickness" })
|
||||
{
|
||||
var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == title);
|
||||
Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
|
||||
Assert.Equal(10, LeftIndent(RowAfter(label)));
|
||||
}
|
||||
|
||||
// Remove and Blur are indented under Background; Strength one level deeper,
|
||||
// under Blur.
|
||||
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Remove")));
|
||||
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Blur")));
|
||||
Assert.Equal(20, LeftIndent(RowContaining(webcamPanel, "Strength")));
|
||||
}
|
||||
|
||||
/// <summary>The shape option bound to the given model property.</summary>
|
||||
private static XElement OptionBoundTo(XDocument doc, string property) =>
|
||||
doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
|
||||
&& ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
|
||||
|
||||
/// <summary>The row (DockPanel) that holds the given label.</summary>
|
||||
private static XElement RowContaining(XElement scope, string label) =>
|
||||
scope.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == label).Parent!;
|
||||
|
||||
/// <summary>The row that follows a title.</summary>
|
||||
private static XElement RowAfter(XElement title)
|
||||
{
|
||||
var next = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>The left inset of an element's Margin, in pixels.</summary>
|
||||
private static int LeftIndent(XElement element)
|
||||
{
|
||||
var margin = (string?)element.Attribute("Margin");
|
||||
if (string.IsNullOrWhiteSpace(margin)) return 0;
|
||||
var first = margin.Split(',')[0].Trim();
|
||||
return int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user