Compare commits

...

11 Commits

Author SHA1 Message Date
gramps 3107f928ab refactor: extract recording-output concern into MainViewModel.Recording.cs — Commit F
The original premise (extract FFmpegEncoder / StreamHealthMonitor / FramePump
classes) was a no-op — those already exist as Services/Encoder/FfmpegEncoder.cs
and Services/Encoder/FramePump.cs. The honest functional seam was the local
recording-output concern, which was interleaved with live-stream orchestration:

- New MainViewModel.Recording.cs (121): StartRecordFile, FinalizeRecordingAsync,
  UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + the
  recording fields (_recordFolder, RecordFolderDisplay, _activeRecordPath,
  _recordStartTime, _recordLength).
- MainViewModel.Streaming.Operations.cs 475 -> 377 (keeps live session lifecycle,
  health, visuals).
- MainViewModel.Streaming.cs 328 -> 323 (keeps pills/state/properties intact).

Same partial class — all MVVM bound-property glue untouched, no behavior change.
Build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker updated in same commit.
2026-08-31 10:21:47 -07:00
gramps 5b9ff5b742 handoff: Phase 3 complete (all production .cs <= 500), push pending for A-E + docs
Rewrite HANDOFF.md: Phase 2 is pushed at 1f4624c (stale claim corrected);
Phase 3 commit rows A-E + Controls/index.md listed as local-only, push pending
on explicit approval. Records the re-structuring principle (split by concern,
500-line count is a ceiling not the goal) and the session's truncation-accident
lesson. MyMistakes.md gains the never-awk-into-the-source-you-read rule.
2026-08-31 09:11:32 -07:00
gramps dcb3637cca refactor: split LayoutStore by concern — 500-limit Phase 3, Commit E
LayoutStore.cs 1402 -> 47-line shell + 5 partials, grouped by functionality
(line count is a consequence, not the goal):

- LayoutStore.Migrations.cs: all DB schema migration (EnsureSchema, GetUserVersion,
  MigrateSourceTable/WebcamConfigTable/ToV3/SceneTable/SceneSocialBarColumn/
  SocialsTable/SocialEntryTable)
- LayoutStore.Load.cs: Load() scene/source read-back
- LayoutStore.Save.cs: Save() full scene tree write
- LayoutStore.Settings.cs: key/value settings + mic/record-folder/reusable-stream
  + license/transition/broadcast/hotkey/window-state GetSetting/UpsertSetting
- LayoutStore.Assets.cs: GetAssetBytes/UpsertAsset blob IO
- LayoutStore.cs: shell — Instance, fields, ctor, Dispose

Converted to public partial class LayoutStore : IDisposable. Pruned per-file
dead usings. Phase 3 DOWN: no production .cs exceeds 500 lines.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 09:10:17 -07:00
gramps d3271a0405 refactor: split SocialsDialogViewModel under 500 lines — 500-limit Phase 3, Commit D
SocialsDialogViewModel.cs 515 -> 334. Extracted the DialogEntry record + the
row-level SocialSlotViewModel (service/logo/lock/edit/validate state, computed
Show* flags) into a new SocialSlotViewModel.cs partial. The dialog VM keeps the
sign-in gate, validation orchestration, and commit. DialogEntry stays public in
the same namespace.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 08:59:38 -07:00
gramps fab2e09903 refactor: split MainViewModel.Background under 500 lines — 500-limit Phase 3, Commit C
Background.cs 567 -> 418. Moved the static scene-model helpers
(LoadBackgroundImage, CreatePlaceholderSnapshot, EnsureBackground,
CreateBackground, NormalizeBackgrounds, AllBackgrounds, ResolveAutoCaptureKey,
MonitorKeyPrefix) into a new MainViewModel.Background.Model.cs partial.
Background.cs keeps capture/live state + operations.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:32:45 -07:00
gramps 31362d1a43 refactor: split MainViewModel.Streaming under 500 lines — 500-limit Phase 3, Commit B
Streaming.cs 789 -> 330. Moved the go-live/record/health operation bodies into a
new MainViewModel.Streaming.Operations.cs partial (459 lines: StartSession,
BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync,
PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream,
FinalizeRecordingAsync, UniquePath, Choose/ResetRecordFolder, DefaultRecordFolder,
OnFramePumpFailed/HealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions).
Streaming.cs keeps state, quality/resolution tiers, session timer, command
declarations. Pruned dead usings in both files.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:30:59 -07:00
gramps 6944db8ab4 refactor: split MainViewModel core under 500 lines — 500-limit Phase 3, Commit A
Core MainViewModel.cs 758 -> 495 lines. Moved the source add/remove/image
block (AddSource, NextSourceName, AddImage, BuildImageCandidates,
PickImageBytes, AddAsset, AddImageSource, AddReusedImage, RemoveElement) into a
new MainViewModel.Sources.cs partial, and WebView2 source hosting
(RegisterLoadedWebSources, InitWebView2, OnWebView2PreviewBitmapChanged) into a
new MainViewModel.Web.cs partial. Pruned now-dead usings from core.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:09:02 -07:00
gramps cb54637e9f docs: add Controls/index.md — the missing per-directory map for Phase-2 UserControls
Phase 2 (refactor-commit-12..18) created Controls/ with 6 UserControls that
split MainWindow.xaml, but no index.md existed — a gap against the schema.md
rule (one index.md per code directory). Documents each control, the window↔
control contract, and the Phase-2 landmines (window-resources converter rule,
control xmlns, name-looked-up host stubs, dropped-Border).
2026-08-30 21:41:18 -07:00
gramps 1f4624c636 docs: rewrite HANDOFF for Phase 2 completion (MainWindow.xaml → 6 controls)
Working tree clean. Phase 1 (pushed) + Phase 2 (local-only, commits 12-18 with
tags) both complete. Push checkpoint reached: next step is 'git push origin main
&& git push --tags' on explicit user approval. Landmines updated with the XAML
extraction gotchas (control xmlns, name-looked-up host stubs, window-resources
converter rule).
2026-08-30 21:16:50 -07:00
gramps 544208e002 refactor: extract OverlayHost (settings/bug/feature/about dialogs) — Phase 2, Commit 18
OverlayHost.xaml(.cs) takes the window-root overlay dialog grid (settings/
bug/feature/about panels); BackToAbout_Click moved in via inherited DataContext.
WebViewHostPanel + ToastArea stay as thin window-root stubs so the WebView2 ctor
init and NotificationService InArea('ToastArea') name lookups are untouched.
This completes Phase 2: MainWindow.xaml is now a 123-line shell.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
2026-08-30 21:07:49 -07:00
gramps c9fd1bdaaa refactor: extract LeftPanel out of MainWindow.xaml — Phase 2, Commit 17
LeftPanel.xaml(.cs) takes the Row 1 Col 0 panel (layers + properties editor and
the live chat list). All property-editor/chat handlers moved into the control
(SourceNameBox edit, AddSourceButton menu, chat autoscroll self-subscribed on
Loaded, mirror/clip-shape, remove-source, background capture change/refresh,
opacity slider label, list drag-to-reorder, snapshot revert/accept family).

Window keeps only shell + routing: ctor wiring (_leftPanel), geometry, hotkeys,
taskbar overlay, BackToAbout, Window_PreviewMouseLeftButtonDown (uses
_leftPanel.IsClickInside), UpdateSelectionOverlay (calls _leftPanel.OnSelectionChanged).

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
2026-08-30 20:09:46 -07:00
26 changed files with 3700 additions and 3427 deletions
+499
View File
@@ -0,0 +1,499 @@
<UserControl x:Class="ytLive.Controls.LeftPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:models="clr-namespace:ytLive.Models">
<Border Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Edit mode (offline): layers + properties -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- LAYERS header + Add button -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcam}"
ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
ToolTip="Timer overlay — Starting and BRB screens only"/>
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
IsEnabled="{Binding CanAddYouTubeChat}"
ToolTip="One chat layer at a time — it's already in your stream"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding StagedScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2"
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<Separator>
<Separator.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</Separator.Visibility>
</Separator>
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Layer Name"
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Remove Layer"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
<!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="3" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8,0,0"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="5" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
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"/>
</StackPanel>
<TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
</StackPanel>
<!-- 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">
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
ToolTip="Pick color"/>
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
MaxLength="7" ToolTip="#RRGGBB"/>
</DockPanel>
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="WebcamBorderAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Countdown config -->
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="CountdownAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="CountdownRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Web source config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, 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 DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<DockPanel Margin="0,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}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
Click="WebAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
Click="WebRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
ToolTip="https://example.com"/>
</DockPanel>
</StackPanel>
<!-- Chat box config -->
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
<!-- Font Size -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Font Family -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
SelectedValuePath="Content"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Segoe UI"/>
<ComboBoxItem Content="Arial"/>
<ComboBoxItem Content="Consolas"/>
<ComboBoxItem Content="Courier New"/>
<ComboBoxItem Content="Comic Sans MS"/>
<ComboBoxItem Content="Mouse Memoirs"/>
</ComboBox>
</DockPanel>
<!-- Font Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Member Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Super Chat Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Max Messages -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Background Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Background Opacity -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
</DockPanel>
<!-- Fade After (seconds) -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
</DockPanel>
<!-- Toggles -->
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
</StackPanel>
<!-- Background properties -->
<StackPanel Margin="0,8,0,0"
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
<StackPanel Margin="4,0,0,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
Margin="8,0,0,0"/>
</StackPanel>
<Grid Margin="0,0,0,4"
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
</Grid>
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
Margin="0,4,0,0"
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="#666" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
</StackPanel>
<!-- Message feed (shown when live) -->
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="#2a1a0a"/>
<Setter Property="BorderBrush" Value="#eab308"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="#eab308" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Border>
</UserControl>
+310
View File
@@ -0,0 +1,310 @@
using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class LeftPanel : UserControl
{
private Models.SceneElement? _snapshotElement;
private string _snapshotBorderColor = string.Empty;
private int _snapshotBorderWidth;
private int _snapshotTimerMinutes = 15;
private string? _snapshotWebUri;
private Point _dragStartPoint;
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
public LeftPanel()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
};
Unloaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
};
}
// Called by the window's UpdateSelectionOverlay (edit mode only).
public void OnSelectionChanged(MainViewModel vm)
{
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
TakeSnapshot(vm);
}
// Deselection guard: true when the click is inside the source list or the
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
public bool IsClickInside(DependencyObject original)
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
}
return child != null;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
box.Focus();
box.SelectAll();
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
}
}
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
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)
vm.RemoveSourceCommand.Execute(element);
}
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
}
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSourceEdit(sender);
}
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var listBox = (ListBox)sender;
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
(DataContext as MainViewModel)!.SelectedElement = null;
_dragIndex = -1;
return;
}
if (listBox.ItemsSource is not IList items || items.Count == 0)
{
_dragIndex = -1;
return;
}
_dragIndex = listBox.Items.IndexOf(item.DataContext);
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (_dragIndex < 0) return;
var listBox = (ListBox)sender;
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
{
EndListDrag(listBox);
return;
}
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
return;
_isDragging = true;
listBox.CaptureMouse();
}
var item = FindItemContainerAt(listBox, position);
if (item == null) return;
var targetItem = item.DataContext;
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_lastHoveredItem = targetItem;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
EndListDrag((ListBox)sender);
}
private void EndListDrag(ListBox listBox)
{
if (_isDragging)
listBox.ReleaseMouseCapture();
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
{
var hit = listBox.InputHitTest(position) as DependencyObject;
while (hit != null && hit != listBox)
{
if (hit is ListBoxItem item) return item;
hit = VisualTreeHelper.GetParent(hit);
}
return null;
}
private void TakeSnapshot(MainViewModel vm)
{
if (vm.SelectedElement is Models.Source src)
{
_snapshotElement = src;
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
_snapshotTimerMinutes = src.TimerMinutes;
_snapshotWebUri = src.WebUri;
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
src.BorderColor = _snapshotBorderColor;
src.BorderWidth = _snapshotBorderWidth;
}
}
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
}
}
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.TimerMinutes = _snapshotTimerMinutes;
}
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotTimerMinutes = src.TimerMinutes;
}
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotWebUri = src.WebUri;
}
}
+172
View File
@@ -0,0 +1,172 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
Command="{Binding CloseOverlayCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"/>
</ControlTemplate>
</Button.Template>
</Button>
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0">
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
Command="{Binding CloseOverlayCommand}">
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
</Button>
</Grid>
<!-- App Settings: broadcast fields live in the preview's
Text pull-out now — this panel keeps app-level prefs only.
(Currently empty; the overlay shell stays for future use.) -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
</StackPanel>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- Feature Request -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Creator hub links -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButtonSecondary}"
IsEnabled="{Binding IsPremiumAvailable}"
ToolTip="Open the LlamaCasty subscription page"/>
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
Style="{StaticResource YtButtonSecondary}"
ToolTip="Activate a license key to remove the watermark"/>
</StackPanel>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Third-party notices &amp; license texts" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
BorderBrush="#333" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,12,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
<!-- License key entry panel -->
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
MaxLength="100"/>
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
Margin="0,0,0,8"/>
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,8,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</UserControl>
+22
View File
@@ -0,0 +1,22 @@
using System.Windows;
using System.Windows.Controls;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class OverlayHost : UserControl
{
public OverlayHost()
{
InitializeComponent();
}
private void BackToAbout_Click(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.IsLicensesOpen = false;
vm.IsLicenseEntryOpen = false;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
# Controls — index
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): gear menu (stop/mic/game-mute/quality dropdown), stream stats (bitrate/fps/dropped/duration/health), resolution dropdown |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 stream controls: REC/ON-AIR pills+signs, elapsed timer, start/stop, account avatar/name (`RefreshAvatar()`) |
| `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) |
| `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 |
| `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 |
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
cross-cutting calls through it:
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
deselection guard in `Window_PreviewMouseLeftButtonDown`.
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
→ selection/edit-mode routing from the VM's `SelectedElement` change.
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
inherit the window's DataContext; never pass the VM through a property.
## Gotchas learned the hard way
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
element lookup, so it stops at the control boundary). The 7 converters
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
on each control's root (LeftPanel hit this).
- **Name-looked-up host stubs must stay in the window**, not a control:
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window root — so `OverlayHost` deliberately leaves them as thin window-root
children (Commit 18).
- When cutting visual wrappers, don't drop the outer `Border`'s
background/glow/margin — Commit 15 initially lost the preview's outer border
and had to restore it same-commit.
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
`MainWindow.xaml` shell at repo root that hosts these.
+70 -56
View File
@@ -2,41 +2,50 @@
## Branch / Commit State ## Branch / Commit State
**`main`**, working tree is **CLEAN**. **`main`**, working tree is **CLEAN**. `origin/main` == `1f4624c` (pushed).
Commits `46978b8` (AGENTS push policy), `fc33022` (HANDOFF), and refactor **Pushed** (reached `origin/main` == `1f4624c`, incl. tags): Phase 1 commit `0`
commits `0–5` through `e286719` are **pushed** (incl. `refactor-commit-0..5`). + refactor commits `1..11`, **and Phase 2 commits `12..18`** (they were pushed
Refactor commits **6–11 are LOCAL ONLY** (policy 2026-08-30: no per-commit at the Phase-2 milestone; the old "Phase 2 local-only" claim below was written
push; `git push --tags` only at a user-agreed milestone / "push it"): before that push and is stale).
| Commit | Hash | Tag | Partial | Lines | **LOCAL ONLY (Phase 3 — A, B, C, D, E) + the Controls doc commit** — policy
|--------|------|-----|---------|-------| 2026-08-30: no per-commit push; `git push` only at a user-agreed milestone /
| 6 Audio | `c1126a5` | `refactor-commit-6` | `MainViewModel.Audio.cs` | 408 | "push it". **Push pending on explicit user approval:**
| 7 Trax | `535a501` | `refactor-commit-7` | `MainViewModel.Trax.cs` | 132 |
| 8 Socials | `c41ff7f` | `refactor-commit-8` | `MainViewModel.Socials.cs` | 161 |
| 9 Streaming | `e1006a0` | `refactor-commit-9` | `MainViewModel.Streaming.cs` | 787 |
| 10 Chat | `3b77a13` | `refactor-commit-10` | `MainViewModel.Chat.cs` | 194 |
| 11 Overlays | `1efde5a` | `refactor-commit-11` | `MainViewModel.Overlays.cs` | 194 |
Nightly summary: **Phase 1 complete.** The plan was 11 partials + scaffold; | Commit | Hash | Tag | Payload |
all live in `ViewModels/`. Core `MainViewModel.cs` shrank 1,890 → **758 |--------|------|-----|---------|
lines**. Every change: clean build (0 warnings), `scripts/verify.sh` (246 | docs | `cb54637` | — | `Controls/index.md` (missing per-directory map) |
passed + 2 known failures only), `scripts/scope-check.sh` with explicit file | A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** |
args, `ViewModels/index.md` updated in the SAME commit, tagged | B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** |
`refactor-commit-N`, no per-commit push. | C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** |
| D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** |
| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials, **≤495** |
## What's In Flight ## What's In Flight
Nothing code-in-flight — working tree clean, Phase 1 done. Nothing code-in-flight — working tree clean, **Phase 3 complete**.
**Next (queued, not started): Phase 2 — split `MainWindow.xaml` (1,681 lines) **Phase 3 result:** every production `.cs` is now **≤ 500 lines** (checked via
into UserControls.** Same rules: one work unit per control, docs in the same `git ls-files '*.cs'` minus tests; nothing exceeds 500). The 500-line count is a
commit, verify + scope-check, commit locally, `git push` only on user go. **hard ceiling, not the goal** — each file was split **grouped by functionality**
so an AI can process one concern per file. Splits were **concern-driven**:
- `MainViewModel` → `.Sources.cs`/`.Web.cs`, `Streaming.cs` + `Streaming.Operations.cs`,
`Background.cs` + `Background.Model.cs`; `SocialSlotViewModel.cs` (row VM).
- `LayoutStore` (was 1402) → `Migrations.cs`/`Load.cs`/`Save.cs`/`Settings.cs`/
`Assets.cs` + a 47-line shell; `public partial class LayoutStore : IDisposable`.
**Push pending:** refactor commits 6–11 + their tags are local. The Session-1 Every change: 0-warning build, `scripts/verify.sh` (246 pass + 2 known only),
push checkpoint was never reached because the user directive was to run the explicit-file `scripts/scope-check.sh`, `ViewModels/index.md` tracker in the
series to completion; Phase 1 completion is the natural milestone to push on SAME commit, tagged `refactor-commit-N`, no per-commit push.
explicit user confirmation.
**Next:**
- If user says "push": `git push origin main && git push --tags` (6 commits:
`cb54637` + A–E, and tags `refactor-commit-A..E`).
**Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
(464), `ViewModels/MainViewModel.Audio.cs` (408).
## Landmines ## Landmines
@@ -45,44 +54,49 @@ explicit user confirmation.
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`. `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`. - **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building. - **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
- **Implicit usings are SDK-default but partials must still carry their own - **WinForm crash from `Window_PreviewMouseLeftButtonDown`** — handled in-side the
domain usings** (they do not inherit core's): `ICommand`/`ObservableCollection`/ preview/left panel; don't add handlers that deref `OriginalSource` unguarded.
`WriteableBitmap`/`DispatcherTimer` etc. are NOT implicit. Grep-verified usings: - **Controls cannot see `Window.Resources`** (converter rule, Commit 13): any
- Audio: Input, Media, Threading, `NAudio.CoreAudioApi`, `ytLive`, Helpers, Models, Services, Services.Audio StaticResource the window once supplied must live at app level in
- Trax: IO, Media, `Microsoft.Win32`, Helpers, Models, Services.Audio `Themes/Controls.xaml`. The 7 converters were promoted there.
- Socials: Windows, Input, Media, `ytLive`, Helpers, Models, Services, Services.Compositor - **Some control XAML needs its own `xmlns:models` / `xmlns:Helpers`** — a moved
- Streaming: ObjectModel, IO, Windows, Input, Threading, `Microsoft.Win32`, Helpers, Models, Services, Services.Encoder block lost its namespace when it left MainWindow's root (LeftPanel hit this,
- Chat: ObjectModel, Media, Media.Imaging, Threading, Helpers, Models, Services MC2000 "key null"). Always add the model/helper xmlns on each control root.
- Overlays: Diagnostics, IO, Input, Helpers - **Name-looked-up host stubs must stay in the window:** `WebViewHostPanel`
(ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window — keep them as window-root children, don't bury them in a control.
OverlayHost deliberately left them in place (Commit 18) for this reason.
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and - **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args. `scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`). - Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
## Todo state ## Todo state
- MainViewModel refactor: **Phase 1 complete (11/11 partials + scaffold).** - MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
`ViewModels/index.md` has a per-partial table row (code↔commit map in the - MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`.
tracker section). Core row now describes only what remains (ctor wiring, - 500-line compliance: **Phase 3 complete (all 5 offenders ≤ 500)**, local-only A–E.
lifecycle, source commands, WebView2 hosting, frame-pump seam,
`SelectedElement`, `BrandFlash*`, `BuildCompositorOptions`/`ResolveOutputFrame`).
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open). - TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending. - TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped. - TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
- TASK 22 (audio sync offset): not started — required for v1. - TASK 22 (audio sync offset): not started — required for v1.
- Monitoring: cross-partial private access is the point of partial classes —
no moved member re-declared anywhere; moved setters reference other partials
purely by name.
## Session summary (2026-08-30) ## Session summary (2026-08-31)
- Ran the MainViewModel split to completion under the standing directive - Ran the **500-line Phase 3 split to completion** under the standing "continue
("continue until you can continue no longer" — no interruption prompts). without stopping" directive — no interruption prompts, no `question` tool.
- Commits 9 (Streaming), 10 (Chat), 11 (Overlays) landed locally, each ~8:40 - Commits A–E landed locally, each: cut → 0-warning build → verify (246 pass + 2
back-to-back: cut → 0-warning build → verify (246 pass + 2 known) → known) → scope-check → index.md in-commit → `git tag refactor-commit-{A..E}`.
scope-check → index.md in-commit → `git tag refactor-commit-N`. No push. - **Re-structuring principle (user correction, 2026-08-31):** split by
- Streaming was the hardest cut (interleaved props, master-frame consts, **functionality**, so an AI can process one concern per file; the 500-line
session timer, health pipeline, record rename-on-stop); Chat and Overlays count is a **ceiling**, NOT the target. Do not pad or reshape code to a
were clean contiguous cuts. One build fix each: Streaming needed number — group by concern and only split enough to stay under the ceiling.
`ObjectModel`/`Input`, Chat needed `Media.Imaging` for `WriteableBitmap`. - **Commit E (LayoutStore)** the biggest: 1402 → 6 concern-based partials
- Docs kept honest throughout: `index.md` rows for Streaming/Chat/Overlays, (Migrations/Load/Save/Settings/Assets/shell), default partial split.
About-hub row re-pointed to the Overlays partial, core row trimmed each time. - **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs`
write that read from the file it was simultaneously truncating left the file at
1 line. Fixed IMMEDIATELY via `git checkout --` and re-ran the split. **Lesson**
(goes in `MyMistakes.md`): never `awk … > SRC` where `awk` also reads `SRC`;
stage every multi-block cut into temp files first, then assemble. All later
LayoutStore cuts did exactly that (read original once into temp files).
- Push checkpoint reached; awaiting explicit "push" for `cb54637` + A–E + tags.
+2 -659
View File
@@ -98,499 +98,8 @@
<!-- LEFT PANEL: one panel, two states. <!-- LEFT PANEL: one panel, two states.
Offline (edit mode): LAYERS + PROPERTIES Offline (edit mode): LAYERS + PROPERTIES
Live/Recording: YouTube Live Chat --> Live/Recording: YouTube Live Chat -->
<Border Grid.Row="1" Grid.Column="0" Background="#16213e" CornerRadius="6" Padding="12"> <controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Edit mode (offline): layers + properties -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- LAYERS header + Add button -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcam}"
ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
ToolTip="Timer overlay — Starting and BRB screens only"/>
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
IsEnabled="{Binding CanAddYouTubeChat}"
ToolTip="One chat layer at a time — it's already in your stream"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding StagedScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2"
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<Separator>
<Separator.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</Separator.Visibility>
</Separator>
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Layer Name"
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Remove Layer"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
<!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="3" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8,0,0"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="5" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
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"/>
</StackPanel>
<TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
</StackPanel>
<!-- 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">
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
ToolTip="Pick color"/>
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
MaxLength="7" ToolTip="#RRGGBB"/>
</DockPanel>
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="WebcamBorderAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Countdown config -->
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="CountdownAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="CountdownRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Web source config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, 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 DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<DockPanel Margin="0,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}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
Click="WebAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
Click="WebRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
ToolTip="https://example.com"/>
</DockPanel>
</StackPanel>
<!-- Chat box config -->
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
<!-- Font Size -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Font Family -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
SelectedValuePath="Content"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Segoe UI"/>
<ComboBoxItem Content="Arial"/>
<ComboBoxItem Content="Consolas"/>
<ComboBoxItem Content="Courier New"/>
<ComboBoxItem Content="Comic Sans MS"/>
<ComboBoxItem Content="Mouse Memoirs"/>
</ComboBox>
</DockPanel>
<!-- Font Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Member Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Super Chat Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Max Messages -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Background Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Background Opacity -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
</DockPanel>
<!-- Fade After (seconds) -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
</DockPanel>
<!-- Toggles -->
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
</StackPanel>
<!-- Background properties -->
<StackPanel Margin="0,8,0,0"
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
<StackPanel Margin="4,0,0,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
Margin="8,0,0,0"/>
</StackPanel>
<Grid Margin="0,0,0,4"
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
</Grid>
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
Margin="0,4,0,0"
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="#666" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
</StackPanel>
<!-- Message feed (shown when live) -->
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="#2a1a0a"/>
<Setter Property="BorderBrush" Value="#eab308"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="#eab308" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Border>
<!-- CENTER: Preview Area --> <!-- CENTER: Preview Area -->
<controls:PreviewPane x:Name="PreviewPane" Grid.Row="1" Grid.Column="1"/> <controls:PreviewPane x:Name="PreviewPane" Grid.Row="1" Grid.Column="1"/>
@@ -599,173 +108,7 @@
<controls:BottomBar Grid.Row="3"/> <controls:BottomBar Grid.Row="3"/>
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ --> <!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<Grid Grid.RowSpan="3" <controls:OverlayHost Grid.RowSpan="3"/>
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
Command="{Binding CloseOverlayCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"/>
</ControlTemplate>
</Button.Template>
</Button>
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0">
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
Command="{Binding CloseOverlayCommand}">
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
</Button>
</Grid>
<!-- App Settings: broadcast fields live in the preview's
Text pull-out now — this panel keeps app-level prefs only.
(Currently empty; the overlay shell stays for future use.) -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
</StackPanel>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- Feature Request -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Creator hub links -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButtonSecondary}"
IsEnabled="{Binding IsPremiumAvailable}"
ToolTip="Open the LlamaCasty subscription page"/>
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
Style="{StaticResource YtButtonSecondary}"
ToolTip="Activate a license key to remove the watermark"/>
</StackPanel>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Third-party notices &amp; license texts" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
BorderBrush="#333" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,12,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
<!-- License key entry panel -->
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
MaxLength="100"/>
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
Margin="0,0,0,8"/>
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,8,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
<!-- ═══ WEB SOURCE HOST: hidden panel for WebView2 controls ═══ --> <!-- ═══ WEB SOURCE HOST: hidden panel for WebView2 controls ═══ -->
<Canvas x:Name="WebViewHostPanel" Grid.RowSpan="4" <Canvas x:Name="WebViewHostPanel" Grid.RowSpan="4"
+4 -289
View File
@@ -1,12 +1,10 @@
using System; using System;
using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels; using ytLive.ViewModels;
namespace ytLive; namespace ytLive;
@@ -16,6 +14,7 @@ public partial class MainWindow : Window
private readonly MainViewModel _viewModel; private readonly MainViewModel _viewModel;
private Controls.TopBar _topBar = null!; private Controls.TopBar _topBar = null!;
private Controls.PreviewPane _previewPane = null!; private Controls.PreviewPane _previewPane = null!;
private Controls.LeftPanel _leftPanel = null!;
public MainWindow() public MainWindow()
{ {
@@ -25,9 +24,9 @@ public partial class MainWindow : Window
_viewModel = (MainViewModel)DataContext; _viewModel = (MainViewModel)DataContext;
_viewModel.InitWebView2(WebViewHostPanel); _viewModel.InitWebView2(WebViewHostPanel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged; _viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
_topBar = (Controls.TopBar)FindName("TopBar")!; _topBar = (Controls.TopBar)FindName("TopBar")!;
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!; _previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
UpdateTaskbarOverlay(); UpdateTaskbarOverlay();
UpdateSelectionOverlay(); UpdateSelectionOverlay();
_topBar.RefreshAvatar(); _topBar.RefreshAvatar();
@@ -136,35 +135,6 @@ public partial class MainWindow : Window
: null; : null;
} }
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
box.Focus();
box.SelectAll();
}
}
private void BackToAbout_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
{
vm.IsLicensesOpen = false;
vm.IsLicenseEntryOpen = false;
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
if (e.OriginalSource is not DependencyObject original) return; if (e.OriginalSource is not DependencyObject original) return;
@@ -172,22 +142,10 @@ public partial class MainWindow : Window
if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original)) if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
form.CloseDrawerCommand.Execute(null); form.CloseDrawerCommand.Execute(null);
if (_previewPane.IsClickInsidePreview(original)) return; if (_previewPane.IsClickInsidePreview(original)) return;
if (IsDescendantOf(original, SourceList)) return; if (_leftPanel.IsClickInside(original)) return;
if (IsDescendantOf(original, ElementsScrollViewer)) return;
_viewModel.SelectedElement = null; _viewModel.SelectedElement = null;
} }
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
}
return child != null;
}
private void UpdateSelectionOverlay() private void UpdateSelectionOverlay()
{ {
if (!_viewModel.IsEditMode) if (!_viewModel.IsEditMode)
@@ -197,249 +155,6 @@ public partial class MainWindow : Window
} }
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s); var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
_previewPane.UpdateSelectionOverlay(selected); _previewPane.UpdateSelectionOverlay(selected);
if (selected) _leftPanel.OnSelectionChanged(_viewModel);
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
TakeSnapshot();
}
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
}
}
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
webcam.ToggleClipShape();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
}
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = _viewModel.ChangeBackgroundCaptureAsync();
}
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
_viewModel.RefreshBackgroundAutoCapture();
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSourceEdit(sender);
}
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
private Point _dragStartPoint;
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var listBox = (ListBox)sender;
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
_viewModel.SelectedElement = null;
_dragIndex = -1;
return;
}
if (listBox.ItemsSource is not IList items || items.Count == 0)
{
_dragIndex = -1;
return;
}
_dragIndex = listBox.Items.IndexOf(item.DataContext);
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (_dragIndex < 0) return;
var listBox = (ListBox)sender;
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
{
EndListDrag(listBox);
return;
}
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
return;
_isDragging = true;
listBox.CaptureMouse();
}
var item = FindItemContainerAt(listBox, position);
if (item == null) return;
var targetItem = item.DataContext;
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_lastHoveredItem = targetItem;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
EndListDrag((ListBox)sender);
}
private void EndListDrag(ListBox listBox)
{
if (_isDragging)
listBox.ReleaseMouseCapture();
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
{
var hit = listBox.InputHitTest(position) as DependencyObject;
while (hit != null && hit != listBox)
{
if (hit is ListBoxItem item) return item;
hit = VisualTreeHelper.GetParent(hit);
}
return null;
}
private Models.SceneElement? _snapshotElement;
private string _snapshotBorderColor = string.Empty;
private int _snapshotBorderWidth;
private int _snapshotTimerMinutes = 15;
private string? _snapshotWebUri;
private void TakeSnapshot()
{
if (_viewModel.SelectedElement is Models.Source src)
{
_snapshotElement = src;
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
_snapshotTimerMinutes = src.TimerMinutes;
_snapshotWebUri = src.WebUri;
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
src.BorderColor = _snapshotBorderColor;
src.BorderWidth = _snapshotBorderWidth;
}
}
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
}
}
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.TimerMinutes = _snapshotTimerMinutes;
}
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotTimerMinutes = src.TimerMinutes;
}
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotWebUri = src.WebUri;
} }
} }
+23
View File
@@ -44,3 +44,26 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
4. Point `README.md` at the `.jpg` (not `.png`). 4. Point `README.md` at the `.jpg` (not `.png`).
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`. **Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
---
## Splitting a large file into partials — NEVER `awk … > SRC` while awking SRC
(2026-08-31, Commit D) Tried to split `SocialsDialogViewModel.cs` in one line:
`{ awk '…' SRC; echo ""; awk '…2…' SRC; } > SRC`. The **first write truncated
SRC to 1 line**, so the second `awk` read the already-truncated file → the whole
source was lost (1 line left). Recovered with `git checkout -- SRC`, then redid
it, but the same bug could have meant making it up from scratch.
**Rule:** when a cut needs N blocks from one source into N files, never write a
block back onto the source that the `awk`s still read. Instead:
1. Read the source **once** at the start into temp files (`mktemp -d`, one file
per block), with a `$D` variable you carry forward.
2. Verify block sizes (`wc -l`) and brace balance (`python3 -c` counting `{`/`}`)
before touching any real file.
3. Then assemble each new file from `cat D/block …` — never truncating the source
until every read is done.
`git status` can't save you here if you don't notice until the file is gone —
`git checkout -- <path>` from the last commit is the recovery. Cheap insurance:
restore-then-retry, do it atomically from temp files the first time.
+41
View File
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public byte[]? GetAssetBytes(string assetId)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
cmd.Parameters.AddWithValue("$id", assetId);
return cmd.ExecuteScalar() as byte[];
}
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
{
var hash = Convert.ToHexString(SHA256.HashData(data));
var id = Guid.NewGuid().ToString();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
VALUES ($id, $hash, $data, $w, $h)
ON CONFLICT(Hash) DO NOTHING;
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$hash", hash);
cmd.Parameters.AddWithValue("$data", data);
cmd.Parameters.AddWithValue("$w", pixelWidth);
cmd.Parameters.AddWithValue("$h", pixelHeight);
cmd.ExecuteNonQuery();
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
return (string)cmd.ExecuteScalar()!;
}
// ─── License (TASK 10 — Polar billing) ───
/// <summary>The saved license key, or null if never entered.</summary>
}
+221
View File
@@ -0,0 +1,221 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public List<Scene> Load()
{
Webcam = null;
Socials = null;
Music = null;
var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
scenes.Add(new Scene
{
Id = reader.GetString(0),
Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0,
HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
});
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Webcam = new Webcam
{
Id = reader.GetString(0),
DeviceId = reader.GetString(1),
Name = reader.GetString(2),
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Socials = new SocialsConfig
{
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
};
var socialsId = reader.GetString(0);
using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
entryCmd.Parameters.AddWithValue("$id", socialsId);
using var entryReader = entryCmd.ExecuteReader();
while (entryReader.Read())
{
Socials.Entries.Add(new SocialEntry
{
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
Handle = entryReader.GetString(1),
ProfileUrl = entryReader.GetString(2),
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
});
}
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Music = new Music
{
TrackPath = reader.GetString(0),
IsEnabled = reader.GetInt32(1) != 0,
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(1);
var type = Enum.Parse<SourceType>(reader.GetString(3));
var source = new Source
{
Id = reader.GetString(0),
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
Type = type,
Name = reader.GetString(4),
IsEnabled = reader.GetInt32(5) != 0,
X = reader.GetDouble(6),
Y = reader.GetDouble(7),
Width = reader.GetDouble(8),
Height = reader.GetDouble(9),
Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0,
// Rows saved before the Type setter derived the flag carry
// IsBackground=0; derive from Type, keeping the persisted bit
// for legacy DisplayCapture backdrops.
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
MediaPlaybackState = reader.IsDBNull(29)
? MediaPlaybackState.Stopped
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
? ps
: MediaPlaybackState.Stopped,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
list.Add(source);
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight
FROM WebcamSceneConfig ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(0);
var config = new WebcamSceneConfig
{
WebcamId = reader.GetString(1),
Name = Webcam?.Name ?? "Webcam",
IsVisible = reader.GetInt32(2) != 0,
X = reader.GetDouble(3),
Y = reader.GetDouble(4),
Width = reader.GetDouble(5),
Height = reader.GetDouble(6),
Opacity = reader.GetDouble(7),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(9) != 0,
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
};
if (!configsByScene.TryGetValue(sceneId, out var list))
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
list.Add(config);
}
}
foreach (var scene in scenes)
{
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
foreach (var source in sources)
scene.Elements.Add(source);
if (configsByScene.TryGetValue(scene.Id, out var configs))
foreach (var config in configs)
scene.Elements.Add(config);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null)
{
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
var path = GetSetting($"BackgroundPath_{bg.Id}");
if (string.IsNullOrWhiteSpace(path))
path = GetSetting($"BackgroundPath_{scene.Id}");
if (!string.IsNullOrWhiteSpace(path))
bg.CustomBackgroundPath = path;
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
if (string.IsNullOrWhiteSpace(useDefault))
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
if (useDefault == "0")
bg.UseDefaultBackground = false;
}
}
return scenes;
}
}
+495
View File
@@ -0,0 +1,495 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
private void EnsureSchema()
{
string[] statements =
{
"""
CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Asset (
Id TEXT PRIMARY KEY,
Hash TEXT NOT NULL UNIQUE,
Data BLOB NOT NULL,
PixelWidth INTEGER NOT NULL DEFAULT 0,
PixelHeight INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT REFERENCES Asset(Id),
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Webcam (
Id TEXT PRIMARY KEY,
DeviceId TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL
);
""",
"""
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
IsVisible INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
RectWidth REAL,
RectHeight REAL,
BorderColor TEXT,
BorderOpacity REAL NOT NULL DEFAULT 1,
BorderWidth INTEGER NOT NULL DEFAULT 0,
BorderAnimation TEXT NOT NULL DEFAULT 'None',
SortOrder INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (SceneId, WebcamId)
);
""",
"""
CREATE TABLE IF NOT EXISTS Socials (
Id TEXT PRIMARY KEY,
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
BarJustify TEXT NOT NULL DEFAULT 'Center'
);
""",
"""
CREATE TABLE IF NOT EXISTS SocialEntry (
Id TEXT PRIMARY KEY,
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
Service TEXT NOT NULL,
Handle TEXT NOT NULL,
ProfileUrl TEXT NOT NULL,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Music (
Id TEXT PRIMARY KEY,
TrackPath TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
""",
};
foreach (var sql in statements)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 9;";
cmd.ExecuteNonQuery();
}
}
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
// get the columns here instead.
private void MigrateSourceTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Source);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("ClipShape"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsMirrored"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (columns.Contains("IsBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
cmd.ExecuteNonQuery();
}
else if (!columns.Contains("IsBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 3.18: ChatBox overlay source
if (!columns.Contains("ChatFontSize"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatFontColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatBackgroundColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMaxMessages"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowTimestamps"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowBadges"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMemberColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatSuperChatColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("WebUri"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 21: Media source (video/audio file playback)
if (!columns.Contains("MediaPath"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaIsLooping"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaVolume"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaPlaybackState"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
cmd.ExecuteNonQuery();
}
// Rename legacy "Backdrop" display name to "Background" in existing rows
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
// so a reloaded Round webcam restores its aspect on toggle-back).
private void MigrateWebcamConfigTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("RectWidth"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("RectHeight"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
cmd.ExecuteNonQuery();
}
}
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
// per scene that had one, then the webcam rows are deleted. No backfill:
// scenes without the webcam stay webcam-free.
private void MigrateToV3()
{
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add((
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDouble(3),
reader.GetDouble(4),
reader.GetDouble(5),
reader.GetDouble(6),
reader.GetDouble(7),
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
reader.GetString(9),
reader.GetInt32(10) != 0));
}
}
if (rows.Count == 0) return;
using var tx = _connection.BeginTransaction();
var webcamId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcamId);
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
cmd.Parameters.AddWithValue("$name", rows[0].Name);
cmd.ExecuteNonQuery();
}
foreach (var row in rows)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
$clip, $mirrored, NULL, 1, 0, 'None', 0);
""";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
cmd.Parameters.AddWithValue("$webcamId", webcamId);
cmd.Parameters.AddWithValue("$x", row.X);
cmd.Parameters.AddWithValue("$y", row.Y);
cmd.Parameters.AddWithValue("$w", row.W);
cmd.Parameters.AddWithValue("$h", row.H);
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
// non-Live canonical scene off and drops their background sources — the fix for
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
cmd.ExecuteNonQuery();
}
else if (columns.Contains("HasBackground"))
{
return;
}
if (!columns.Contains("HasBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"""
DELETE FROM Source
WHERE IsBackground = 1 AND SceneId IN
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
""";
cmd.ExecuteNonQuery();
}
}
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
private void MigrateSceneSocialBarColumn()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasSocialBar")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
alter.ExecuteNonQuery();
}
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
// BarJustify column stays for back-compat but is no longer read or written.
private void MigrateSocialsTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Socials);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("BarEnabled")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
alter.ExecuteNonQuery();
}
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
// software name) so a reloaded entry still shows the right service logo.
private void MigrateSocialEntryTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("Software")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
alter.ExecuteNonQuery();
}
}
+339
View File
@@ -0,0 +1,339 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Webcam;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM SocialEntry;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Socials;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Music;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0;
foreach (var scene in scenes)
{
idP.Value = scene.Id;
nameP.Value = scene.Name;
hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0;
backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
foreach (var scene in scenes)
{
var sort = 0;
foreach (var element in scene.Elements)
{
if (element is not Source source) continue;
idP.Value = source.Id;
sceneIdP.Value = scene.Id;
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
typeP.Value = source.Type.ToString();
nameP.Value = source.Name;
enabledP.Value = source.IsEnabled ? 1 : 0;
xP.Value = source.X;
yP.Value = source.Y;
wP.Value = source.Width;
hP.Value = source.Height;
opacityP.Value = source.Opacity;
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackgroundP.Value = source.IsBackground ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = sort++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
chatBgColorP.Value = source.ChatBackgroundColor;
chatMaxMsgsP.Value = source.ChatMaxMessages;
chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
chatMemberColorP.Value = source.ChatMemberColor;
chatSuperChatColorP.Value = source.ChatSuperChatColor;
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
mediaVolumeP.Value = source.MediaVolume;
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
cmd.ExecuteNonQuery();
}
}
}
if (webcam != null)
{
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcam.Id);
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
cmd.Parameters.AddWithValue("$name", webcam.Name);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder)
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
$rectWidth, $rectHeight, $sort)
""";
cmd.Transaction = tx;
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
foreach (var scene in scenes)
{
var sort = 0;
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
sceneIdP.Value = scene.Id;
webcamIdP.Value = config.WebcamId;
visibleP.Value = config.IsVisible ? 1 : 0;
xP.Value = config.X;
yP.Value = config.Y;
wP.Value = config.Width;
hP.Value = config.Height;
opacityP.Value = config.Opacity;
clipP.Value = config.ClipShape.ToString();
mirroredP.Value = config.IsMirrored ? 1 : 0;
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
borderOpacityP.Value = config.BorderOpacity;
borderWidthP.Value = config.BorderWidth;
animationP.Value = config.BorderAnimation.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
}
}
}
if (socials != null && socials.Entries.Count > 0)
{
var socialsId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", socialsId);
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = """
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
""";
entryCmd.Transaction = tx;
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
var entrySort = 0;
foreach (var entry in socials.Entries)
{
eId.Value = Guid.NewGuid().ToString();
eSocialsId.Value = socialsId;
eService.Value = entry.Service.ToString();
eHandle.Value = entry.Handle;
eUrl.Value = entry.ProfileUrl;
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
eSort.Value = entrySort++;
entryCmd.ExecuteNonQuery();
}
}
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
cmd.Parameters.AddWithValue("$path", music.TrackPath);
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
// Save custom background paths and use-default flags per background source
foreach (var scene in scenes)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg == null) continue;
var pathKey = $"BackgroundPath_{bg.Id}";
if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
UpsertSetting(pathKey, bg.CustomBackgroundPath);
else
{
using var del = _connection.CreateCommand();
del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
del.Parameters.AddWithValue("$key", pathKey);
del.ExecuteNonQuery();
}
var defaultKey = $"BackgroundUseDefault_{bg.Id}";
UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
}
// Purge background settings left behind by deleted/duplicated rows
// (TASK 25 heal drops extras; their keys must not accumulate).
using (var purge = _connection.CreateCommand())
{
purge.Transaction = tx;
purge.CommandText =
@"DELETE FROM Settings
WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
purge.ExecuteNonQuery();
}
tx.Commit();
}
}
+292
View File
@@ -0,0 +1,292 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
/// <summary>The last picked mic source name, or null if never chosen. Restored
/// at startup so the sound bar reconnects to the same, already-vetted device.</summary>
public string? LoadMicSourceName()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveMicSourceName(string? name)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(name))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
insert.Parameters.AddWithValue("$name", name);
insert.ExecuteNonQuery();
}
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
public string? LoadRecordFolder()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveRecordFolder(string? folder)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(folder))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
insert.Parameters.AddWithValue("$folder", folder);
insert.ExecuteNonQuery();
}
/// <summary>The cached reusable stream (TASK 5), or null if never created.
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
/// instead of waiting on the YouTube round-trip.</summary>
public ReusableStream? LoadReusableStream()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
var value = cmd.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return null;
var parts = value.Split('\u001F');
return parts.Length == 3
? new ReusableStream(parts[0], parts[1], parts[2])
: null;
}
public void SaveReusableStream(ReusableStream? stream)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
cmd.ExecuteNonQuery();
if (stream == null) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
insert.ExecuteNonQuery();
}
public string? LoadLicenseKey()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveLicenseKey(string? key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(key)) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
insert.Parameters.AddWithValue("$value", key);
insert.ExecuteNonQuery();
}
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
public DateTime? LoadLicenseValidatedAt()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
var value = cmd.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, out var dt) ? dt : null;
}
public void SaveLicenseValidatedAt(DateTime? dt)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
cmd.ExecuteNonQuery();
if (dt == null) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
insert.ExecuteNonQuery();
}
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
/// grace period — the app checks online at startup but uses this cached
/// value when offline.</summary>
public bool LoadIsPremium()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
var value = cmd.ExecuteScalar() as string;
return value == "1";
}
public void SaveIsPremium(bool isPremium)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
cmd.ExecuteNonQuery();
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
insert.ExecuteNonQuery();
}
public (string TransitionType, double DurationMs) LoadTransitionSettings()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
var type = cmd.ExecuteScalar() as string;
using var cmd2 = _connection.CreateCommand();
cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
var dur = cmd2.ExecuteScalar() as string;
return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
}
public void SaveTransitionSettings(string transitionType, double durationMs)
{
UpsertSetting("TransitionType", transitionType);
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
}
// ─── Broadcast metadata (broadcast pull-out) + window geometry ───
public Models.BroadcastMetadata LoadBroadcastMetadata()
{
return new Models.BroadcastMetadata
{
Title = GetSetting("Broadcast.Title") ?? string.Empty,
Description = GetSetting("Broadcast.Description") ?? string.Empty,
TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
Visibility = GetSetting("Broadcast.Visibility") ?? "private",
MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
};
}
public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
{
UpsertSetting("Broadcast.Title", meta.Title);
UpsertSetting("Broadcast.Description", meta.Description);
UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
UpsertSetting("Broadcast.Visibility", meta.Visibility);
UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
UpsertSetting("Broadcast.ScheduledStartTime",
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
}
// ─── Hotkey bindings (TASK 20 step 2) ───
/// <summary>
/// Load saved hotkey bindings. Returns only entries that have been explicitly
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
/// Caller fills in defaults for any missing entries as needed.
/// </summary>
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
{
var result = new Dictionary<HotkeyId, HotkeyChord>();
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
var raw = GetSetting($"Hotkey.{id}");
if (raw == null) continue; // not saved = unbound
if (raw == "none") continue; // explicitly unbound
try
{
result[id] = HotkeyChord.ParseStorage(raw);
}
catch (FormatException)
{
// corrupt entry — skip, will fall back to default
}
}
return result;
}
/// <summary>
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
/// as "none"). Only the provided entries are saved; previously saved entries
/// for HotkeyIds not in the dictionary are deleted.
/// </summary>
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
{
// Delete all existing hotkey settings first
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
cmd.ExecuteNonQuery();
}
// Save the new bindings
foreach (var (id, chord) in bindings)
{
var value = chord?.ToString() ?? "none";
UpsertSetting($"Hotkey.{id}", value);
}
}
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
{
var left = GetSetting("Window.Left");
var top = GetSetting("Window.Top");
var width = GetSetting("Window.Width");
var height = GetSetting("Window.Height");
var state = GetSetting("Window.State");
if (left == null || top == null || width == null || height == null) return null;
if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
!double.TryParse(width, out var w) || !double.TryParse(height, out var h))
return null;
return (l, t, w, h, state ?? "Normal");
}
public void SaveWindowState(double left, double top, double width, double height, string state)
{
UpsertSetting("Window.Left", left.ToString("F0"));
UpsertSetting("Window.Top", top.ToString("F0"));
UpsertSetting("Window.Width", width.ToString("F0"));
UpsertSetting("Window.Height", height.ToString("F0"));
UpsertSetting("Window.State", state);
}
private string? GetSetting(string key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
return cmd.ExecuteScalar() as string;
}
private void UpsertSetting(string key, string value)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
cmd.ExecuteNonQuery();
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
insert.Parameters.AddWithValue("$key", key);
insert.Parameters.AddWithValue("$value", value);
insert.ExecuteNonQuery();
}
}
+1 -1356
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null && !bg.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
&& System.IO.File.Exists(bg.CustomBackgroundPath))
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(bg.CustomBackgroundPath);
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
catch { /* fall through to AssetId */ }
}
if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId))
return ImageCache.Get(bg.AssetId);
return null;
}
private static BitmapSource CreatePlaceholderSnapshot(string sceneName)
{
var colors = new Dictionary<string, System.Windows.Media.Brush>(StringComparer.OrdinalIgnoreCase)
{
["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)),
["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)),
["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)),
["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)),
["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)),
};
var brush = colors.TryGetValue(sceneName, out var c) ? c
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80));
var dv = new System.Windows.Media.DrawingVisual();
using (var ctx = dv.RenderOpen())
{
ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180));
var ft = new System.Windows.Media.FormattedText(
sceneName,
System.Globalization.CultureInfo.InvariantCulture,
System.Windows.FlowDirection.LeftToRight,
new System.Windows.Media.Typeface("Segoe UI"),
18,
System.Windows.Media.Brushes.White,
96);
ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2));
}
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
// ─── Screen background capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
internal static Source? EnsureBackground(Scene scene)
{
if (!scene.HasBackground) return null;
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
background = CreateBackground(scene.Name);
scene.Elements.Insert(0, background);
return background;
}
/// <summary>The one Background row for a scene. Live gets the capture flavor
/// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
/// live-background.jpg; every other screen is a static default-art layer.
/// Both are named "Background" — the user never sees capture jargon here.</summary>
internal static Source CreateBackground(string sceneName)
{
var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
return new Source
{
Name = "Background",
Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
}
/// <summary>Heals any loaded layout to the locked Background model: exactly
/// one Background per canonical scene, correct flavor for the scene (static
/// art everywhere, DisplayCapture on Live only), at index 0, named
/// "Background". Duplicates and stale rows are dropped; missing ones seeded;
/// non-canonical scenes get HasBackground=false and lose any backgrounds.</summary>
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
if (!SceneCatalog.HasBackground(scene.Name))
{
scene.HasBackground = false;
foreach (var stale in scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList())
scene.Elements.Remove(stale);
continue;
}
scene.HasBackground = true;
var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
var candidates = scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList();
var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
scene.Elements.Remove(extra);
if (keep == null)
{
keep = CreateBackground(scene.Name);
scene.Elements.Insert(0, keep);
}
// Source.Type's setter derives IsBackground from the enum, so a
// flavor correction must re-assert the flag afterwards.
if (keep.Type != wantedType)
{
keep.Type = wantedType;
keep.IsBackground = true;
}
keep.Name = "Background";
keep.IsEnabled = true;
var index = scene.Elements.IndexOf(keep);
if (index > 0)
scene.Elements.Move(index, 0);
}
}
// Only the Live screen's background is a real-time surface. Every other
// lookup below (auto-capture, focus hooks, refresh) must ignore the static
// default-art rows so they can never start a desktop capture session.
private IEnumerable<Source> AllBackgrounds()
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
}
+1 -151
View File
@@ -146,156 +146,6 @@ public partial class MainViewModel
} }
} }
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null && !bg.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
&& System.IO.File.Exists(bg.CustomBackgroundPath))
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(bg.CustomBackgroundPath);
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
catch { /* fall through to AssetId */ }
}
if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId))
return ImageCache.Get(bg.AssetId);
return null;
}
private static BitmapSource CreatePlaceholderSnapshot(string sceneName)
{
var colors = new Dictionary<string, System.Windows.Media.Brush>(StringComparer.OrdinalIgnoreCase)
{
["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)),
["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)),
["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)),
["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)),
["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)),
};
var brush = colors.TryGetValue(sceneName, out var c) ? c
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80));
var dv = new System.Windows.Media.DrawingVisual();
using (var ctx = dv.RenderOpen())
{
ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180));
var ft = new System.Windows.Media.FormattedText(
sceneName,
System.Globalization.CultureInfo.InvariantCulture,
System.Windows.FlowDirection.LeftToRight,
new System.Windows.Media.Typeface("Segoe UI"),
18,
System.Windows.Media.Brushes.White,
96);
ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2));
}
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
// ─── Screen background capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
internal static Source? EnsureBackground(Scene scene)
{
if (!scene.HasBackground) return null;
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
background = CreateBackground(scene.Name);
scene.Elements.Insert(0, background);
return background;
}
/// <summary>The one Background row for a scene. Live gets the capture flavor
/// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
/// live-background.jpg; every other screen is a static default-art layer.
/// Both are named "Background" — the user never sees capture jargon here.</summary>
internal static Source CreateBackground(string sceneName)
{
var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
return new Source
{
Name = "Background",
Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
}
/// <summary>Heals any loaded layout to the locked Background model: exactly
/// one Background per canonical scene, correct flavor for the scene (static
/// art everywhere, DisplayCapture on Live only), at index 0, named
/// "Background". Duplicates and stale rows are dropped; missing ones seeded;
/// non-canonical scenes get HasBackground=false and lose any backgrounds.</summary>
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
if (!SceneCatalog.HasBackground(scene.Name))
{
scene.HasBackground = false;
foreach (var stale in scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList())
scene.Elements.Remove(stale);
continue;
}
scene.HasBackground = true;
var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
var candidates = scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList();
var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
scene.Elements.Remove(extra);
if (keep == null)
{
keep = CreateBackground(scene.Name);
scene.Elements.Insert(0, keep);
}
// Source.Type's setter derives IsBackground from the enum, so a
// flavor correction must re-assert the flag afterwards.
if (keep.Type != wantedType)
{
keep.Type = wantedType;
keep.IsBackground = true;
}
keep.Name = "Background";
keep.IsEnabled = true;
var index = scene.Elements.IndexOf(keep);
if (index > 0)
scene.Elements.Move(index, 0);
}
}
// Only the Live screen's background is a real-time surface. Every other
// lookup below (auto-capture, focus hooks, refresh) must ignore the static
// default-art rows so they can never start a desktop capture session.
private IEnumerable<Source> AllBackgrounds()
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
// Full-screen game on its monitor, else null (static bg fallback — no // Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before // infinity mirror). Called at launch (from ReacquireScreenCaptures, before
@@ -565,4 +415,4 @@ public partial class MainViewModel
} }
catch { /* non-critical — the app works without default art */ } catch { /* non-critical — the app works without default art */ }
} }
} }
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private string? _recordFolder;
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
var dir = Path.GetDirectoryName(startPath)!;
// Manual rename modal: give the creator a chance to name the recording;
// null = keep the auto name (or the default recorded above).
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder set", _recordFolder);
}
}
private void ResetRecordFolder()
{
_recordFolder = null;
_layoutStore.SaveRecordFolder(null);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder reset", RecordFolderDisplay);
}
internal static string DefaultRecordFolder()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads)
? downloads
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
}
+251
View File
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private void AddSource(object? parameter)
{
var scene = StagedScene;
if (scene == null) return;
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
var baseName = sourceType switch
{
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
_ => "Layer",
};
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted. YouTube Chat is one-per-layout.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
var webCount = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.WebSource);
var source = new Source
{
Name = sourceType == SourceType.WebSource
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
};
scene.Elements.Add(source);
if (sourceType == SourceType.WebSource)
{
_webView2Manager?.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager?.OnWebUriChanged(source);
};
}
if (sourceType == SourceType.ChatBox)
{
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(source);
};
UpdateChatBoxPreview(source);
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
private static string NextSourceName(Scene scene, string baseName)
{
var taken = scene.Elements.OfType<Source>()
.Select(s => s.Name)
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|| (n.Length > baseName.Length
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(n.Substring(baseName.Length), out _)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!taken.Contains(baseName)) return baseName;
for (var i = 2; ; i++)
if (!taken.Contains($"{baseName}{i}"))
return $"{baseName}{i}";
}
private void AddImage()
{
var scene = StagedScene;
if (scene == null) return;
var candidates = BuildImageCandidates();
if (candidates.Count == 0)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
return;
}
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true) return;
if (dialog.WantsNew)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
}
else
{
AddReusedImage(dialog.PickedAssetId!);
}
}
private List<ReuseImageCandidate> BuildImageCandidates()
{
var candidates = new List<ReuseImageCandidate>();
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{
candidates.Add(new ReuseImageCandidate
{
SceneName = scene.Name,
SourceName = source.Name,
AssetId = source.AssetId!
});
}
return candidates;
}
private byte[]? PickImageBytes(string title)
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return null;
try
{
return File.ReadAllBytes(dialog.FileName);
}
catch (Exception ex)
{
_notifications.Warning("Couldn't read that image", ex.Message);
return null;
}
}
private string? AddAsset(byte[] bytes)
{
try
{
var image = ImageCache.FromBytes(bytes);
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
if (id != null && image != null) ImageCache.Put(id, image);
return id;
}
catch (Exception ex)
{
Debug.WriteLine($"Asset store failed: {ex.Message}");
return null;
}
}
private void AddImageSource(byte[] bytes)
{
if (bytes.Length == 0) return;
var assetId = AddAsset(bytes);
if (assetId != null) AddReusedImage(assetId);
}
private void AddReusedImage(string assetId)
{
var scene = StagedScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
{
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
source.Width = image.PixelWidth * scale;
source.Height = image.PixelHeight * scale;
source.X = (1920 - source.Width) / 2;
source.Y = (1080 - source.Height) / 2;
}
scene.Elements.Add(source);
SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
if (scene == null || element == null) return;
if (element is Source { IsBackground: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
if (element is Source { Type: SourceType.WebSource } webSource)
_webView2Manager?.Unregister(webSource.Id);
scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
}
@@ -0,0 +1,377 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
private void StartSession()
{
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
if (!streaming && !recording) return;
if (!streaming)
{
BeginRecordOnly();
return;
}
BeginGoLive(recording);
}
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
/// is local and works signed-out. Generates the auto-named file and starts the
/// pipeline immediately.</summary>
private async void BeginRecordOnly()
{
var path = StartRecordFile();
if (path == null)
{
_notifications.Error("Couldn't record", "No record output path could be created.");
return;
}
_recordStartTime = DateTime.Now;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
await _framePump.StartAsync();
}
private void BeginGoLive(bool alsoRecord)
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = BroadcastForm.Title,
StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "LlamaCasty"
: $"{dialog.StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
{
if (StartRecordFile() == null)
{
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
}
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
/// before the pump starts — hence the ordering here. Failures surface as an
/// error health state, never a crash.</summary>
private async Task PrepareAndStartLiveAsync()
{
try
{
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
AppLog.Write("Reusable stream unavailable; check the OAuth session");
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
_reusableStream = stream;
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;
_notifications.Warning("Complete your broadcast details",
"Your title and description are empty — fill them in from the Text panel on the right.");
}
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
// the stream continues without on-panel chat.
try
{
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
}
catch (Exception ex)
{
AppLog.Write($"Go-live preparation failed: {ex.Message}");
_notifications.Error("Couldn't go live", ex.Message);
StreamStatus = StreamStatus.Error;
return;
}
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
// live. Report-by-exception — a banner appears only on warning/error.
_healthPollTimer.Start();
_ = PollHealthAsync();
}
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
/// health state + the report-by-exception banner. Never throws — a poll
/// failure logs and leaves the last state untouched.</summary>
private async Task PollHealthAsync()
{
if (_reusableStream == null || !IsLive) return;
try
{
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
AppLog.Write("Stream health poll failed; check the OAuth session");
return;
}
CurrentHealth.HealthStatus = health.HealthStatus;
CurrentHealth.HealthMessage = health.HealthStatus;
CurrentHealth.ConfigurationIssues.Clear();
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
OnPropertyChanged(nameof(CurrentHealth));
ApplyHealthIssue(health.ConfigurationIssues);
}
catch (Exception ex)
{
AppLog.Write($"Stream health poll failed: {ex.Message}");
}
}
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
// the first warning/error issue raises it, colored by the worst severity.
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
{
var report = StreamHealthReporter.BannerFor(issues);
if (report.Text == null)
{
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
return;
}
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
HealthIssueBanner = report.Text;
}
private async void StopStream()
{
var wasLive = IsLive;
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline;
IsRecording = false;
WindowTitle = "LlamaCasty";
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
_youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the output. Audio capture itself is always-on (preview
// monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive();
await _framePump.StopAsync();
_currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId));
if (wasRecording) await FinalizeRecordingAsync();
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
// the session/token — the creator signs out deliberately via the
// Logout / Change Account commands.
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
}
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary>
/// <summary>Falls back to the user's Downloads folder when no preference is set;
/// returns MyVideos if Downloads doesn't exist.</summary>
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url ?? string.Empty,
StreamEnabled = OnAirPillOn,
RecordEnabled = RecordPillOn,
RecordPath = _activeRecordPath,
Width = quality.Width,
Height = quality.Height,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
private void OnFramePumpFailed(object? sender, string message)
{
AppLog.Write($"Frame pump failed: {message}");
_notifications.Error("The stream pipeline stopped", message);
if (IsLive) StreamStatus = StreamStatus.Error;
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
CurrentHealth.HealthStatus = null;
CurrentHealth.HealthMessage = null;
CurrentHealth.ConfigurationIssues.Clear();
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals()
{
var live = IsLive;
TopBarBackground = live ? "#e94560" : "#16213e";
PreviewGlowBrush = live ? "#e94560" : "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (live)
{
if (BrandFlashEnabled) StartBrandFlashTimer();
}
else
{
_healthPollTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
UpdateSessionTimer();
}
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
private void UpdateSessionTimer()
{
var active = IsLive || IsRecording;
if (active)
{
_recDotPulse = 1.0;
_liveTimer.Start();
}
else
{
_liveTimer.Stop();
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
}
private void OnLiveTimerTick(object? sender, EventArgs e)
{
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
}
-466
View File
@@ -1,9 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Win32;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
@@ -40,14 +38,9 @@ public partial class MainViewModel : ViewModelBase
private bool _recordPillOn; private bool _recordPillOn;
private bool _onAirPillOn; private bool _onAirPillOn;
private bool _isRecording; private bool _isRecording;
private string? _recordFolder;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary> /// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private readonly FramePump _framePump; private readonly FramePump _framePump;
@@ -327,463 +320,4 @@ public partial class MainViewModel : ViewModelBase
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand ChooseRecordFolderCommand { get; } public ICommand ChooseRecordFolderCommand { get; }
public ICommand ResetRecordFolderCommand { get; } public ICommand ResetRecordFolderCommand { get; }
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
private void StartSession()
{
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
if (!streaming && !recording) return;
if (!streaming)
{
BeginRecordOnly();
return;
}
BeginGoLive(recording);
}
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
/// is local and works signed-out. Generates the auto-named file and starts the
/// pipeline immediately.</summary>
private async void BeginRecordOnly()
{
var path = StartRecordFile();
if (path == null)
{
_notifications.Error("Couldn't record", "No record output path could be created.");
return;
}
_recordStartTime = DateTime.Now;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
await _framePump.StartAsync();
}
private void BeginGoLive(bool alsoRecord)
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = BroadcastForm.Title,
StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "LlamaCasty"
: $"{dialog.StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
{
if (StartRecordFile() == null)
{
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
}
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
/// before the pump starts — hence the ordering here. Failures surface as an
/// error health state, never a crash.</summary>
private async Task PrepareAndStartLiveAsync()
{
try
{
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
AppLog.Write("Reusable stream unavailable; check the OAuth session");
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
_reusableStream = stream;
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;
_notifications.Warning("Complete your broadcast details",
"Your title and description are empty — fill them in from the Text panel on the right.");
}
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
// the stream continues without on-panel chat.
try
{
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
}
catch (Exception ex)
{
AppLog.Write($"Go-live preparation failed: {ex.Message}");
_notifications.Error("Couldn't go live", ex.Message);
StreamStatus = StreamStatus.Error;
return;
}
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
// live. Report-by-exception — a banner appears only on warning/error.
_healthPollTimer.Start();
_ = PollHealthAsync();
}
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
/// health state + the report-by-exception banner. Never throws — a poll
/// failure logs and leaves the last state untouched.</summary>
private async Task PollHealthAsync()
{
if (_reusableStream == null || !IsLive) return;
try
{
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
AppLog.Write("Stream health poll failed; check the OAuth session");
return;
}
CurrentHealth.HealthStatus = health.HealthStatus;
CurrentHealth.HealthMessage = health.HealthStatus;
CurrentHealth.ConfigurationIssues.Clear();
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
OnPropertyChanged(nameof(CurrentHealth));
ApplyHealthIssue(health.ConfigurationIssues);
}
catch (Exception ex)
{
AppLog.Write($"Stream health poll failed: {ex.Message}");
}
}
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
// the first warning/error issue raises it, colored by the worst severity.
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
{
var report = StreamHealthReporter.BannerFor(issues);
if (report.Text == null)
{
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
return;
}
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
HealthIssueBanner = report.Text;
}
private async void StopStream()
{
var wasLive = IsLive;
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline;
IsRecording = false;
WindowTitle = "LlamaCasty";
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
_youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the output. Audio capture itself is always-on (preview
// monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive();
await _framePump.StopAsync();
_currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId));
if (wasRecording) await FinalizeRecordingAsync();
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
// the session/token — the creator signs out deliberately via the
// Logout / Change Account commands.
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
}
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary>
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
var dir = Path.GetDirectoryName(startPath)!;
// Manual rename modal: give the creator a chance to name the recording;
// null = keep the auto name (or the default recorded above).
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder set", _recordFolder);
}
}
private void ResetRecordFolder()
{
_recordFolder = null;
_layoutStore.SaveRecordFolder(null);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder reset", RecordFolderDisplay);
}
/// <summary>Falls back to the user's Downloads folder when no preference is set;
/// returns MyVideos if Downloads doesn't exist.</summary>
internal static string DefaultRecordFolder()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads)
? downloads
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url ?? string.Empty,
StreamEnabled = OnAirPillOn,
RecordEnabled = RecordPillOn,
RecordPath = _activeRecordPath,
Width = quality.Width,
Height = quality.Height,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
private void OnFramePumpFailed(object? sender, string message)
{
AppLog.Write($"Frame pump failed: {message}");
_notifications.Error("The stream pipeline stopped", message);
if (IsLive) StreamStatus = StreamStatus.Error;
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
CurrentHealth.HealthStatus = null;
CurrentHealth.HealthMessage = null;
CurrentHealth.ConfigurationIssues.Clear();
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals()
{
var live = IsLive;
TopBarBackground = live ? "#e94560" : "#16213e";
PreviewGlowBrush = live ? "#e94560" : "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (live)
{
if (BrandFlashEnabled) StartBrandFlashTimer();
}
else
{
_healthPollTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
UpdateSessionTimer();
}
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
private void UpdateSessionTimer()
{
var active = IsLive || IsRecording;
if (active)
{
_recDotPulse = 1.0;
_liveTimer.Start();
}
else
{
_liveTimer.Stop();
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
}
private void OnLiveTimerTick(object? sender, EventArgs e)
{
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
} }
+41
View File
@@ -0,0 +1,41 @@
using System.Linq;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private void RegisterLoadedWebSources()
{
if (_webView2Manager == null) return;
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.WebSource))
{
_webView2Manager.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager.OnWebUriChanged(source);
};
}
}
public void InitWebView2(Panel hostPanel)
{
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
_webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged;
RegisterLoadedWebSources();
}
private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap)
{
foreach (var scene in Scenes)
foreach (var el in scene.Elements.OfType<Source>().Where(s => s.Id == sourceId))
el.VideoImageSource = bitmap;
}
}
-268
View File
@@ -1,5 +1,3 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -8,10 +6,7 @@ using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Interop; using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Win32;
using NAudio.CoreAudioApi;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
@@ -383,35 +378,6 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
private void RegisterLoadedWebSources()
{
if (_webView2Manager == null) return;
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.WebSource))
{
_webView2Manager.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager.OnWebUriChanged(source);
};
}
}
public void InitWebView2(Panel hostPanel)
{
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
_webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged;
RegisterLoadedWebSources();
}
private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap)
{
foreach (var scene in Scenes)
foreach (var el in scene.Elements.OfType<Source>().Where(s => s.Id == sourceId))
el.VideoImageSource = bitmap;
}
public void Shutdown() public void Shutdown()
{ {
@@ -463,240 +429,6 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(BackgroundVisible)); OnPropertyChanged(nameof(BackgroundVisible));
} }
private void AddSource(object? parameter)
{
var scene = StagedScene;
if (scene == null) return;
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
var baseName = sourceType switch
{
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
_ => "Layer",
};
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted. YouTube Chat is one-per-layout.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
var webCount = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.WebSource);
var source = new Source
{
Name = sourceType == SourceType.WebSource
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
};
scene.Elements.Add(source);
if (sourceType == SourceType.WebSource)
{
_webView2Manager?.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager?.OnWebUriChanged(source);
};
}
if (sourceType == SourceType.ChatBox)
{
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(source);
};
UpdateChatBoxPreview(source);
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
private static string NextSourceName(Scene scene, string baseName)
{
var taken = scene.Elements.OfType<Source>()
.Select(s => s.Name)
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|| (n.Length > baseName.Length
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(n.Substring(baseName.Length), out _)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!taken.Contains(baseName)) return baseName;
for (var i = 2; ; i++)
if (!taken.Contains($"{baseName}{i}"))
return $"{baseName}{i}";
}
private void AddImage()
{
var scene = StagedScene;
if (scene == null) return;
var candidates = BuildImageCandidates();
if (candidates.Count == 0)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
return;
}
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true) return;
if (dialog.WantsNew)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
}
else
{
AddReusedImage(dialog.PickedAssetId!);
}
}
private List<ReuseImageCandidate> BuildImageCandidates()
{
var candidates = new List<ReuseImageCandidate>();
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{
candidates.Add(new ReuseImageCandidate
{
SceneName = scene.Name,
SourceName = source.Name,
AssetId = source.AssetId!
});
}
return candidates;
}
private byte[]? PickImageBytes(string title)
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return null;
try
{
return File.ReadAllBytes(dialog.FileName);
}
catch (Exception ex)
{
_notifications.Warning("Couldn't read that image", ex.Message);
return null;
}
}
private string? AddAsset(byte[] bytes)
{
try
{
var image = ImageCache.FromBytes(bytes);
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
if (id != null && image != null) ImageCache.Put(id, image);
return id;
}
catch (Exception ex)
{
Debug.WriteLine($"Asset store failed: {ex.Message}");
return null;
}
}
private void AddImageSource(byte[] bytes)
{
if (bytes.Length == 0) return;
var assetId = AddAsset(bytes);
if (assetId != null) AddReusedImage(assetId);
}
private void AddReusedImage(string assetId)
{
var scene = StagedScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
{
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
source.Width = image.PixelWidth * scale;
source.Height = image.PixelHeight * scale;
source.X = (1920 - source.Width) / 2;
source.Y = (1080 - source.Height) / 2;
}
scene.Elements.Add(source);
SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
if (scene == null || element == null) return;
if (element is Source { IsBackground: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
if (element is Source { Type: SourceType.WebSource } webSource)
_webView2Manager?.Unregister(webSource.Id);
scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Scene-element → latest frame, for the live compositor. The map mirrors the // Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the background) by CaptureKey, // preview: webcam by DeviceId, live captures (the background) by CaptureKey,
+183
View File
@@ -0,0 +1,183 @@
using System.ComponentModel;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.ViewModels;
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
/// <summary>
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
/// the dialog's working list, so deleting an entry makes the ones above advance up.
/// </summary>
public sealed class SocialSlotViewModel : ViewModelBase
{
public int Index { get; }
public bool IsYoutube => Index == 0;
private SocialService _service = SocialService.Link;
public SocialService Service
{
get => _service;
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
}
public string LogoData => Service == SocialService.Fediverse
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public string LockedIconData => SocialServiceIcons.LockedIconData;
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
private bool _isLocked;
public bool IsLocked
{
get => _isLocked;
set
{
if (SetProperty(ref _isLocked, value))
{
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isFilled;
public bool IsFilled
{
get => _isFilled;
set
{
if (SetProperty(ref _isFilled, value))
{
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isSignIn;
public bool IsSignIn
{
get => _isSignIn;
set
{
if (SetProperty(ref _isSignIn, value))
{
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowLogo));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set
{
if (SetProperty(ref _isEditing, value))
{
OnPropertyChanged(nameof(ShowEditBox));
OnPropertyChanged(nameof(ShowDisplay));
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private bool _isValidating;
public bool IsValidating
{
get => _isValidating;
set
{
if (SetProperty(ref _isValidating, value))
{
OnPropertyChanged(nameof(ShowBusy));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _handle = string.Empty;
public string Handle
{
get => _handle;
set
{
if (SetProperty(ref _handle, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private string? _fediverseSoftware;
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (SetProperty(ref _fediverseSoftware, value))
OnPropertyChanged(nameof(LogoData));
}
}
public string ProfileUrl { get; set; } = string.Empty;
private string? _error;
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(ShowError));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _editText = string.Empty;
public string EditText
{
get => _editText;
set => SetProperty(ref _editText, value);
}
/// <summary>The last text submitted for validation (used to skip re-validating
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
public string? LastValidatedInput { get; set; }
public string DisplayText =>
IsYoutube
? (IsSignIn ? "Sign in to YouTube" : Handle)
: IsLocked ? "Unlock with Premium"
: IsFilled ? Handle
: "No social yet";
public bool HasError => !string.IsNullOrEmpty(Error);
public bool ShowError => HasError;
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
public bool ShowBusy => IsValidating;
public bool ShowEditBox => IsEditing;
public bool ShowDisplay => !IsEditing && !IsValidating;
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
public SocialSlotViewModel(int index) => Index = index;
}
-181
View File
@@ -8,187 +8,6 @@ using ytLive.Services;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
/// <summary>
/// A validated social entry in the dialog's working copy (non-YouTube slots).
/// </summary>
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
/// <summary>
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
/// the dialog's working list, so deleting an entry makes the ones above advance up.
/// </summary>
public sealed class SocialSlotViewModel : ViewModelBase
{
public int Index { get; }
public bool IsYoutube => Index == 0;
private SocialService _service = SocialService.Link;
public SocialService Service
{
get => _service;
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
}
public string LogoData => Service == SocialService.Fediverse
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public string LockedIconData => SocialServiceIcons.LockedIconData;
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
private bool _isLocked;
public bool IsLocked
{
get => _isLocked;
set
{
if (SetProperty(ref _isLocked, value))
{
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isFilled;
public bool IsFilled
{
get => _isFilled;
set
{
if (SetProperty(ref _isFilled, value))
{
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isSignIn;
public bool IsSignIn
{
get => _isSignIn;
set
{
if (SetProperty(ref _isSignIn, value))
{
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowLogo));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set
{
if (SetProperty(ref _isEditing, value))
{
OnPropertyChanged(nameof(ShowEditBox));
OnPropertyChanged(nameof(ShowDisplay));
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private bool _isValidating;
public bool IsValidating
{
get => _isValidating;
set
{
if (SetProperty(ref _isValidating, value))
{
OnPropertyChanged(nameof(ShowBusy));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _handle = string.Empty;
public string Handle
{
get => _handle;
set
{
if (SetProperty(ref _handle, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private string? _fediverseSoftware;
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (SetProperty(ref _fediverseSoftware, value))
OnPropertyChanged(nameof(LogoData));
}
}
public string ProfileUrl { get; set; } = string.Empty;
private string? _error;
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(ShowError));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _editText = string.Empty;
public string EditText
{
get => _editText;
set => SetProperty(ref _editText, value);
}
/// <summary>The last text submitted for validation (used to skip re-validating
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
public string? LastValidatedInput { get; set; }
public string DisplayText =>
IsYoutube
? (IsSignIn ? "Sign in to YouTube" : Handle)
: IsLocked ? "Unlock with Premium"
: IsFilled ? Handle
: "No social yet";
public bool HasError => !string.IsNullOrEmpty(Error);
public bool ShowError => HasError;
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
public bool ShowBusy => IsValidating;
public bool ShowEditBox => IsEditing;
public bool ShowDisplay => !IsEditing && !IsValidating;
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
public SocialSlotViewModel(int index) => Index = index;
}
/// <summary> /// <summary>
/// View model for the Social Media Site Promotion dialog. Owns the working copy /// View model for the Social Media Site Promotion dialog. Owns the working copy
/// of the social bar config: the six fixed slots, the sign-in gate, per-row /// of the social bar config: the six fixed slots, the sign-in gate, per-row
+13 -1
View File
@@ -58,7 +58,19 @@ Progress (Commit N/12 · tag):
- [x] **Commit 13 · `refactor-commit-13`** — **HealthBanner** → `Controls/HealthBanner.xaml` (Row 1). Also promoted the 7 value↔visibility converters out of `MainWindow.Window.Resources` into `Themes/Controls.xaml` so extracted controls can resolve them (app-level, not window-level). - [x] **Commit 13 · `refactor-commit-13`** — **HealthBanner** → `Controls/HealthBanner.xaml` (Row 1). Also promoted the 7 value↔visibility converters out of `MainWindow.Window.Resources` into `Themes/Controls.xaml` so extracted controls can resolve them (app-level, not window-level).
- [x] **Commit 14 · `refactor-commit-14`** — **TopBar** → `Controls/TopBar.xaml(.cs)` (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes `_topBar.RefreshAvatar()` invoked on `IsConnected`/`AccountAvatarUrl` changes. - [x] **Commit 14 · `refactor-commit-14`** — **TopBar** → `Controls/TopBar.xaml(.cs)` (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes `_topBar.RefreshAvatar()` invoked on `IsConnected`/`AccountAvatarUrl` changes.
- [x] **Commit 15 · `refactor-commit-15`** — **PreviewPane** → `Controls/PreviewPane.xaml(.cs)` (Row 1 Col 1 center preview pane). Moved the 1920×1080 canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement), text pull-out, and the live-controls row (TRAX/game/mic meters + sliders + speakers + socials button). Code-behind moved: preview drag/select/resize/hit-test (Preview_Mouse*, HitHandle, HitElement), webcam-context-menu handlers (WebcamMenu_*), social-bar glow (SocialBar_*, SocialsButton_*, SocialGlowTick), audio sliders/speakers (VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*, SetSliderValueFromClick, FindParent), TraxButton_*. Window keeps the source-list left panel + thumbnail strip + edit UI, and routes selection-overlay via `_previewPane.UpdateSelectionOverlay(bool)` plus `_previewPane.IsClickInsidePreview/Drawer`. `BackgroundMenu_*` stayed in the window (used by the left-panel source context menu). - [x] **Commit 15 · `refactor-commit-15`** — **PreviewPane** → `Controls/PreviewPane.xaml(.cs)` (Row 1 Col 1 center preview pane). Moved the 1920×1080 canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement), text pull-out, and the live-controls row (TRAX/game/mic meters + sliders + speakers + socials button). Code-behind moved: preview drag/select/resize/hit-test (Preview_Mouse*, HitHandle, HitElement), webcam-context-menu handlers (WebcamMenu_*), social-bar glow (SocialBar_*, SocialsButton_*, SocialGlowTick), audio sliders/speakers (VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*, SetSliderValueFromClick, FindParent), TraxButton_*. Window keeps the source-list left panel + thumbnail strip + edit UI, and routes selection-overlay via `_previewPane.UpdateSelectionOverlay(bool)` plus `_previewPane.IsClickInsidePreview/Drawer`. `BackgroundMenu_*` stayed in the window (used by the left-panel source context menu).
- [x] **Commit 16 · `refactor-commit-16`** — **SceneThumbnailStrip** → `Controls/SceneThumbnailStrip.xaml(.cs)` (Row 0 scene-picker strip). `Thumbnail_MouseLeftButtonDown` moved into the control (transitions via `DataContext.TransitionToScene`). Pending: left scene/chat panel, overlay host. - [x] **Commit 16 · `refactor-commit-16`** — **SceneThumbnailStrip** → `Controls/SceneThumbnailStrip.xaml(.cs)` (Row 0 scene-picker strip). `Thumbnail_MouseLeftButtonDown` moved into the control (transitions via `DataContext.TransitionToScene`).
- [x] **Commit 17 · `refactor-commit-17`** — **LeftPanel** → `Controls/LeftPanel.xaml(.cs)` (Row 1 Col 0 left panel: layers/properties edit UI + live chat list). Moved ALL property-editor and chat handlers: `SourceNameBox_*`/`CommitSourceEdit`, `AddSourceButton_Click`, `OnChatMessagesChanged` (now self-subscribed on Loaded), `MirrorButton_Click`, `ShapeButton_Click`, `SourceMenu_Remove`, `BackgroundMenu_*`, `OpacitySlider_ValueChanged`, list drag-to-reorder (`_dragStartPoint`+`List_Preview*`+`EndListDrag`+`FindItemContainerAt`), and the snapshot family (`TakeSnapshot`, `WebcamBorder*`, `Countdown*`, `Web*`). Window exposes via `_leftPanel.IsClickInside(original)` (deselect guard) and `_leftPanel.OnSelectionChanged(vm)` (opacity label + snapshot). Pending: overlay host region.
- [x] **Commit 18 · `refactor-commit-18`** — **OverlayHost** → `Controls/OverlayHost.xaml(.cs)` (settings/bug/feature/about dialogs). `BackToAbout_Click` moved with it (uses inherited `DataContext`). Window keeps the thin `WebViewHostPanel` + `ToastArea` root stubs so name lookups are untouched (WebView2 ctor init, `NotificationService.InArea("ToastArea")`) — toast/WebView2 behavior unchanged. **Phase 2 COMPLETE** — MainWindow.xaml carved down to a 123-line shell over 6 controls (TopBar, HealthBanner, BottomBar, SceneThumbnailStrip, LeftPanel, PreviewPane, OverlayHost). Pending: session wrap-up (HANDOFF.md) + push on approval.
**Phase 3 — 500-line compliance (production `.cs` only).** Bring all production
source files under 500 lines. One roll-back commit per file; same drill
(0-warning build, `verify.sh` 246+2, explicit-file scope-check, tracker in same
commit, tag `refactor-commit-N`, no per-commit push).
- [x] **Commit A · `refactor-commit-A`** — **core** → `MainViewModel.cs` 758 → **495**. Moved source add/remove/image block to new **`MainViewModel.Sources.cs`** (234 lines: `AddSource`, `NextSourceName`, `AddImage`, `BuildImageCandidates`, `PickImageBytes`, `AddAsset`, `AddImageSource`, `AddReusedImage`, `RemoveElement`) and WebView2 hosting (`RegisterLoadedWebSources`, `InitWebView2`, `OnWebView2PreviewBitmapChanged`) to new **`MainViewModel.Web.cs`**. Pruned now-dead usings (`Microsoft.Win32`, `NAudio.CoreAudioApi`, `System.Windows.Media.Imaging`, `Collections.ObjectModel/Specialized`). Pending: Streaming, Background, SocialsDialogViewModel, LayoutStore.
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
- [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.**
- [x] **Commit F · `refactor-commit-F`** — **Recording (context/concern)** → new **`MainViewModel.Recording.cs`** (121 lines). The encoder (`Services/Encoder/FfmpegEncoder.cs`) and frame pump (`Services/Encoder/FramePump.cs`) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: `StartRecordFile`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder` + fields `_recordFolder`/`RecordFolderDisplay`/`_activeRecordPath`/`_recordStartTime`/`_recordLength` moved out of the live-stream orchestration. `MainViewModel.Streaming.Operations.cs` 475 → **377**; `MainViewModel.Streaming.cs` 328 → **323**. Same partial class, MVVM glue untouched.
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
proceeds; each partial carries its own `using`s and re-declares nothing from core. proceeds; each partial carries its own `using`s and re-declares nothing from core.