Compare commits
16 Commits
5437c776fe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d90b5ded0d | |||
| 8292663791 | |||
| dc29adf9bb | |||
| 18abe4210f | |||
| 9f9ed34627 | |||
| ea250c02a6 | |||
| ac60a26d82 | |||
| e72ba71165 | |||
| 58c0f8e8c4 | |||
| ba427e85e1 | |||
| e494dce311 | |||
| b094755e08 | |||
| 7c456042a6 | |||
| 9873eeda7d | |||
| db5fc06174 | |||
| 8868f98392 |
@@ -41,8 +41,10 @@ at a problem.
|
||||
|
||||
- **Never work without the map.** If the map contradicts the code, the code wins
|
||||
and the map gets fixed in the same change (stale facts are corrected, not appended).
|
||||
- **Every feature change ships with its memory update:** `ai.md` for
|
||||
architecture/patterns, `TASKS.md` for status, index files when layout changes.
|
||||
- **Every feature change ships with its memory update in the SAME commit:**
|
||||
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
|
||||
layout changes. No code commit without its docs — a follow-up "docs backfill"
|
||||
commit is a broken rule, not a style.
|
||||
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
|
||||
Never end a session with uncommitted work unrecorded — the handoff names the
|
||||
branch, the dirty files, and why it stopped.
|
||||
|
||||
+77
-6
@@ -5,10 +5,81 @@
|
||||
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
|
||||
> end, compaction, or any interruption.
|
||||
|
||||
## Session state (last updated: 2026-08-12)
|
||||
## Session state (last updated: 2026-08-13)
|
||||
|
||||
- **Finished:** Webcam resource validation (merged to main, `e1d9e13`) + Social bar (on branch `social-bar`, ready to merge). Social bar: `Models/Socials.cs` (19 services + icons/URLs), `Services/SocialValidator.cs` (HTTP lookup seam), schema v7 (`Socials` + `SocialEntry` tables + `Scene.HasSocialBar`), footer Social button + [+/-] toggle, preview bar rendering (top/bottom, LCR), freemium cap (YT+1, premium seam). 85 tests passing, 0 warnings.
|
||||
- **In flight:** Social bar needs merge to main + push, then local testing.
|
||||
- **Landmine:** 19041 SDK projection gaps (see Branch A). `HttpSocialValidator` uses synchronous `.GetAwaiter().GetResult()` in the dialog flow — fine for a UI-blocking dialog, but a proper async dialog is a follow-up. The social dialog is currently a simple `ShowInputDialog` (inline WPF Window) — a proper dialog window with service picker + URL field + validation feedback is a UX follow-up.
|
||||
- **Next step:** Merge `social-bar` to main, push. Then: local test the social bar (add a social, verify bar renders, toggle on/off scenes). Then: continue TASK 4 (encoder + RTMP push) or the next queued item.
|
||||
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v7); OAuth callback `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
||||
- **Branch:** `main`, in sync with `origin/main`.
|
||||
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
|
||||
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
|
||||
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
|
||||
persisted and still dims a hidden row to 45%. Removed dead surface:
|
||||
`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
|
||||
`BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
|
||||
2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
|
||||
`EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
|
||||
lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
|
||||
the eye style now binds `IsVisible` and hidden rows dim to 45%.
|
||||
3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`,
|
||||
`Image3`… (no space), next free number derived from actual names so deletions never
|
||||
collide. Used by both `AddSource` and `AddReusedImage`.
|
||||
4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
|
||||
`SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
|
||||
5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
|
||||
on resize; the preview absorbs it. No change.
|
||||
6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
|
||||
throttling when unfocused + GPU readback contention + the `_framePending` /
|
||||
`DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
|
||||
- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
|
||||
share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
|
||||
serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
|
||||
that calls `new App()` directly again — marshal onto `RealAppHost` instead.
|
||||
- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
|
||||
(commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (−`IsEditing`),
|
||||
`Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
|
||||
(new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
|
||||
(now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
|
||||
- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
|
||||
- **Landmines:**
|
||||
- Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
|
||||
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
||||
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
||||
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
|
||||
`OnLoopbackSample` compute the level first, then raise the event — a
|
||||
`?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
|
||||
nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
|
||||
- `MicConnected` comes from the source `Started` event, raised right after
|
||||
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
|
||||
before asserting connection state.
|
||||
- The mic dot is red until a resource connects (see contract below) — green
|
||||
only after `Started`, yellow on `Failed`.
|
||||
- Zero mic devices at startup = red dot AND the mixer is never started, so
|
||||
loopback + the game bar can't run either (no capture at all) — acceptable.
|
||||
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
|
||||
`OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
|
||||
- The pump reads the active scene on a background thread while the UI can still
|
||||
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
|
||||
the pump stops), not a crash.
|
||||
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
|
||||
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
|
||||
order deadlocks.
|
||||
- Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
|
||||
on the `RealAppHost` STA thread (round-clip + naming), which never go live.
|
||||
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
|
||||
only, never the real instance.
|
||||
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
|
||||
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
|
||||
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
|
||||
from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
|
||||
devices at startup → stays red and the mixer is never started; capture failure → yellow.
|
||||
- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4
|
||||
requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the
|
||||
dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's
|
||||
private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice
|
||||
filters). Nothing else queued — do not expand the task queue on your own. Optional, not
|
||||
queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
|
||||
(user must say the word).
|
||||
|
||||
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
|
||||
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
|
||||
layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; `SocialEntry.Software`
|
||||
column is a column-presence migration like the others, no version bump); OAuth callback
|
||||
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
||||
|
||||
+207
-134
@@ -49,21 +49,21 @@
|
||||
</DrawingImage.Drawing>
|
||||
</DrawingImage>
|
||||
|
||||
<!-- Eye icon: open when visible, slashed when hidden -->
|
||||
<!-- Eye icon: open when visible, slashed when hidden (source rows) -->
|
||||
<Style x:Key="EyeIconStyle" TargetType="Path">
|
||||
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
|
||||
<Setter Property="ToolTip" Value="Hide Scene"/>
|
||||
<Setter Property="ToolTip" Value="Hide Source"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||
<Setter Property="ToolTip" Value="Show Scene"/>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="ToolTip" Value="Show Source"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
@@ -86,27 +86,32 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Logo / Brand -->
|
||||
<TextBlock Grid.Column="0" Text="ytLlive"
|
||||
Foreground="#e94560" FontSize="22" FontWeight="Bold"
|
||||
VerticalAlignment="Center" Margin="0,0,24,0"/>
|
||||
<!-- Logo / Brand — clicking opens the in-app About overlay -->
|
||||
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
|
||||
Command="{Binding OpenAboutCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
|
||||
</Button>
|
||||
|
||||
<!-- LIVE badge (center) -->
|
||||
<!-- REC sign (center): dark offline, red live, darker red when private -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center"
|
||||
Opacity="{Binding LivePulseOpacity}"/>
|
||||
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
||||
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
|
||||
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"/>
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Single three-state action button -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Content="About" Style="{StaticResource YtButtonSecondary}"
|
||||
Click="AboutButton_Click" Margin="0,0,12,0" VerticalAlignment="Center"/>
|
||||
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
ToolTip="{Binding AccountDisplayName}"
|
||||
@@ -188,64 +193,8 @@
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<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="SceneNameBox_IsVisibleChanged"
|
||||
KeyDown="SceneNameBox_KeyDown" LostFocus="SceneNameBox_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 Scene Name"
|
||||
Command="{Binding DataContext.EditSceneCommand, 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.ToggleSceneVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}">
|
||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||
Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" ToolTip="Delete Scene"
|
||||
Command="{Binding DataContext.RemoveSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<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>
|
||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -282,6 +231,11 @@
|
||||
<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>
|
||||
@@ -290,6 +244,8 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.ContextMenu>
|
||||
@@ -305,9 +261,51 @@
|
||||
</Grid.ContextMenu>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis"/>
|
||||
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>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Remove Source"
|
||||
<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 Source 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 Source"
|
||||
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
||||
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
|
||||
@@ -652,13 +650,21 @@
|
||||
</Viewbox>
|
||||
</Grid>
|
||||
|
||||
<!-- Social bar: horizontal chip row at the top or bottom of the
|
||||
frame, justified LCR. Global resource, shown per-scene. -->
|
||||
<Grid Width="1920" Canvas.Top="{Binding SocialBarTop}" IsHitTestVisible="False"
|
||||
Visibility="{Binding SceneHasSocialBar, Converter={StaticResource BoolToVis}}">
|
||||
<!-- Social bar: one centered horizontal row at the top or bottom of
|
||||
the frame, green glow while enabled. Global resource — never a
|
||||
Source, no Sources entry. Click toggles top ⇄ bottom. -->
|
||||
<Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
|
||||
Background="Transparent"
|
||||
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
|
||||
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
|
||||
Cursor="Hand">
|
||||
<Grid.Effect>
|
||||
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
|
||||
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
|
||||
</Grid.Effect>
|
||||
<ItemsControl ItemsSource="{Binding Socials.Entries}"
|
||||
HorizontalAlignment="{Binding SocialBarAlign}"
|
||||
Margin="40,8">
|
||||
HorizontalAlignment="Center" Margin="40,8"
|
||||
IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
@@ -667,19 +673,11 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
|
||||
<Border Width="28" Height="28" CornerRadius="14"
|
||||
Background="{Binding AccentColor}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Initials}" Foreground="White"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
|
||||
Stretch="Uniform" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Handle}" Foreground="White"
|
||||
FontSize="14" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="200"/>
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -742,6 +740,63 @@
|
||||
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Game audio bar: overlaid at the bottom of the preview
|
||||
window while a full-screen game is producing sound
|
||||
(the game audio detector). Monitoring UI only — it
|
||||
lives in the XAML preview and never reaches the live
|
||||
output. Desktop audio itself is automatic WASAPI
|
||||
loopback. -->
|
||||
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Background="#D9000000" CornerRadius="6" Padding="12,8"
|
||||
Margin="0,0,0,12"
|
||||
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Game Audio Capture" Foreground="#a0a0b0" FontSize="11"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="Desktop/game audio via WASAPI loopback (automatic)"/>
|
||||
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
|
||||
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
|
||||
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
|
||||
<Path Fill="#d0d0d0" Stretch="Uniform"
|
||||
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Path Fill="#ef4444" Stretch="Uniform"
|
||||
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||
Data="M21,3 L3,21"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
<Slider Width="130" Minimum="0" Maximum="1"
|
||||
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
|
||||
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -795,16 +850,47 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Line 1: sound meter + volume control, centered beneath the
|
||||
middle (preview) panel. Audio is KISS: desktop/game audio is
|
||||
automatic ("it just is" — WASAPI loopback at unity, zero UI);
|
||||
the creator's only audio control is the mic — meter, volume, mute. -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
|
||||
ToolTip="Choose the microphone (voice source)"
|
||||
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/>
|
||||
<!-- Line 1: Social controls on the LEFT (under scenes/sources),
|
||||
sound meter + volume control CENTERED beneath the preview panel.
|
||||
Audio is KISS: desktop/game audio is automatic (WASAPI loopback
|
||||
at unity); the mic meter is the creator's only footer audio
|
||||
control (the game audio bar lives INSIDE the preview window,
|
||||
overlaid at its bottom edge while a full-screen game is up). -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="300"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Social controls: centered under the scenes/sources listboxes -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
ToolTip="Add or manage your social handles">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Socials"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Mic meter + volume: centered under the preview panel -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button Command="{Binding OpenMicPickerCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="{Binding MicStatusToolTip}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="MIC"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- Sound meter: muted track with a ruler scale, zone tints at
|
||||
the yellow (60%) and red (80%) starts. READ-ONLY realtime
|
||||
level display (fill = live level scaled by volume). -->
|
||||
@@ -836,6 +922,19 @@
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
|
||||
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
|
||||
<Path Fill="#d0d0d0" Stretch="Uniform"
|
||||
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Path Fill="#ef4444" Stretch="Uniform"
|
||||
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||
Data="M21,3 L3,21"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
|
||||
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
|
||||
@@ -854,34 +953,8 @@
|
||||
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||
|
||||
<Separator Margin="16,0,0,0" Background="#2a3a5e" Width="1" Height="20"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="Social" Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4" Margin="8,0,0,0"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
ToolTip="Add or manage your social handles"/>
|
||||
<Button Command="{Binding ToggleSceneSocialBarCommand}"
|
||||
Visibility="{Binding CanToggleSceneSocialBar, Converter={StaticResource BoolToVis}}"
|
||||
Style="{StaticResource IconButton}" Width="28" Height="28" Margin="4,0,0,0"
|
||||
VerticalAlignment="Center" FontSize="16" FontWeight="Bold"
|
||||
ToolTip="Add or remove the social bar on this scene">
|
||||
<Button.Content>
|
||||
<TextBlock FontSize="16" FontWeight="Bold">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="+"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding SceneHasSocialBar}" Value="True">
|
||||
<Setter Property="Text" Value="−"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Line 2: everything else — stream stats on the left, quality +
|
||||
gear on the right. -->
|
||||
|
||||
+43
-31
@@ -1,7 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
@@ -65,7 +63,7 @@ public partial class MainWindow : Window
|
||||
: null;
|
||||
}
|
||||
|
||||
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
@@ -83,12 +81,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void MicLabel_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.OpenMicPickerCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
@@ -107,6 +99,24 @@ public partial class MainWindow : Window
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
@@ -115,6 +125,14 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
@@ -125,21 +143,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void AboutButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// LGPL/BSD/MIT notices ship next to the exe; open in the OS text viewer.
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "About: failed to open THIRD-PARTY-NOTICES.txt");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
@@ -254,6 +257,15 @@ public partial class MainWindow : Window
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_viewModel.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (OpacityChip.IsMouseOver) return;
|
||||
@@ -360,24 +372,24 @@ public partial class MainWindow : Window
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSceneEdit(sender);
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSceneEdit(sender);
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSceneEdit(object sender)
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: Scene scene })
|
||||
scene.IsEditing = false;
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>Footer mic indicator state (the dot beside the MIC button):
|
||||
/// <see cref="Connected"/> = mic capture is running; <see cref="Problem"/> = a
|
||||
/// mic was requested but the connection failed (device in use, unplugged, or
|
||||
/// unavailable); <see cref="NotConnected"/> = no capture is active (e.g. no
|
||||
/// mic device present).</summary>
|
||||
public enum MicStatus
|
||||
{
|
||||
NotConnected,
|
||||
Connected,
|
||||
Problem
|
||||
}
|
||||
@@ -9,7 +9,6 @@ public class Scene : INotifyPropertyChanged
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
private string _name = string.Empty;
|
||||
private bool _isEditing;
|
||||
private bool _isHidden;
|
||||
private bool _hasBackdrop;
|
||||
private bool _hasSocialBar;
|
||||
@@ -20,12 +19,6 @@ public class Scene : INotifyPropertyChanged
|
||||
set => Set(ref _name, value);
|
||||
}
|
||||
|
||||
public bool IsEditing
|
||||
{
|
||||
get => _isEditing;
|
||||
set => Set(ref _isEditing, value);
|
||||
}
|
||||
|
||||
public bool IsHidden
|
||||
{
|
||||
get => _isHidden;
|
||||
|
||||
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
private bool _isVisible = true;
|
||||
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
|
||||
|
||||
private bool _isEditing;
|
||||
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
||||
|
||||
private ClipShape _clipShape = ClipShape.Traditional;
|
||||
public ClipShape ClipShape
|
||||
{
|
||||
|
||||
+181
-55
@@ -8,11 +8,10 @@ public enum SocialService
|
||||
{
|
||||
YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick,
|
||||
Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit,
|
||||
WhatsApp, Telegram, Link, Website
|
||||
WhatsApp, Telegram, Link, Website, Fediverse
|
||||
}
|
||||
|
||||
public enum SocialBarPosition { Top, Bottom }
|
||||
public enum SocialBarJustify { Left, Center, Right }
|
||||
|
||||
public sealed class SocialEntry : INotifyPropertyChanged
|
||||
{
|
||||
@@ -20,9 +19,27 @@ public sealed class SocialEntry : INotifyPropertyChanged
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
|
||||
private string? _fediverseSoftware;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.
|
||||
/// Settable so the load-time heal can fill a missing name and the bar icon
|
||||
/// updates in place.</summary>
|
||||
public string? FediverseSoftware
|
||||
{
|
||||
get => _fediverseSoftware;
|
||||
set
|
||||
{
|
||||
if (_fediverseSoftware == value) return;
|
||||
_fediverseSoftware = value;
|
||||
Raise(nameof(FediverseSoftware));
|
||||
Raise(nameof(LogoData));
|
||||
}
|
||||
}
|
||||
|
||||
public string ServiceName => Service.ToString();
|
||||
public string Initials => SocialServiceIcons.InitialsFor(Service);
|
||||
public string AccentColor => SocialServiceIcons.ColorFor(Service);
|
||||
public string LogoData => Service == SocialService.Fediverse
|
||||
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
|
||||
: SocialServiceIcons.LogoDataFor(Service);
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Raise([CallerMemberName] string? name = null)
|
||||
@@ -30,15 +47,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The app-wide social bar config — a global resource (like the webcam) whose
|
||||
/// per-scene presence is toggled by <see cref="Scene.HasSocialBar"/>. The creator
|
||||
/// provides handles/URLs; the app validates each by looking up the social page
|
||||
/// before adding it. Freemium: YT + 1 other. Premium: as many as fit one line.
|
||||
/// The app-wide social bar config — a global resource (like the webcam) that
|
||||
/// every scene shows when <see cref="BarEnabled"/> is on and entries exist. The
|
||||
/// dialog provides the handles; the app validates each by looking up the social
|
||||
/// page before committing. Freemium: YT + 1 other. Premium: all six slots.
|
||||
/// </summary>
|
||||
public sealed class SocialsConfig : INotifyPropertyChanged
|
||||
{
|
||||
public ObservableCollection<SocialEntry> Entries { get; } = new();
|
||||
|
||||
private bool _barEnabled = true;
|
||||
public bool BarEnabled
|
||||
{
|
||||
get => _barEnabled;
|
||||
set => Set(ref _barEnabled, value);
|
||||
}
|
||||
|
||||
private SocialBarPosition _barPosition = SocialBarPosition.Bottom;
|
||||
public SocialBarPosition BarPosition
|
||||
{
|
||||
@@ -46,13 +70,6 @@ public sealed class SocialsConfig : INotifyPropertyChanged
|
||||
set => Set(ref _barPosition, value);
|
||||
}
|
||||
|
||||
private SocialBarJustify _barJustify = SocialBarJustify.Center;
|
||||
public SocialBarJustify BarJustify
|
||||
{
|
||||
get => _barJustify;
|
||||
set => Set(ref _barJustify, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
@@ -63,56 +80,100 @@ public sealed class SocialsConfig : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brand logo path data (Simple Icons, CC0) plus the small utility glyphs the
|
||||
/// social dialog uses — lock (Premium gate), do-not (empty/invalid slot) and a
|
||||
/// generic link (bare Link/Website entries). All strings are 24x24 path data
|
||||
/// consumed by a Path.Data binding (the Geometry type converter handles the string).
|
||||
/// </summary>
|
||||
public static class SocialServiceIcons
|
||||
{
|
||||
public static string InitialsFor(SocialService service) => service switch
|
||||
public const string LockedIconData = "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z";
|
||||
public const string DoNotIconData = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z";
|
||||
public const string LinkIconData = "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z";
|
||||
|
||||
private const string YouTubePath = "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z";
|
||||
private const string TwitchPath = "M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z";
|
||||
private const string XPath = "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z";
|
||||
private const string InstagramPath = "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077";
|
||||
private const string TikTokPath = "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z";
|
||||
private const string FacebookPath = "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z";
|
||||
private const string DiscordPath = "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z";
|
||||
private const string KickPath = "M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z";
|
||||
private const string ThreadsPath = "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z";
|
||||
private const string BlueskyPath = "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026";
|
||||
private const string GitHubPath = "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
|
||||
private const string LinkedInPath = "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z";
|
||||
private const string PinterestPath = "M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z";
|
||||
private const string SnapchatPath = "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z";
|
||||
private const string RedditPath = "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z";
|
||||
private const string WhatsAppPath = "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
|
||||
private const string TelegramPath = "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z";
|
||||
|
||||
// Fediverse instance software logos (Simple Icons, CC0). Resolved at
|
||||
// validate-time from each instance's nodeinfo `software.name`; unknown
|
||||
// software falls back to the generic fediverse glyph below.
|
||||
private const string MastodonPath = "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z";
|
||||
private const string PeerTubePath = "M12 6.545v10.91L20.727 12M3.273 12v12L12 17.455M3.273 0v12L12 6.545";
|
||||
private const string PixelfedPath = "M12 24C5.3726 24 0 18.6274 0 12S5.3726 0 12 0s12 5.3726 12 12-5.3726 12-12 12m-.9526-9.3802h2.2014c2.0738 0 3.7549-1.6366 3.7549-3.6554S15.3226 7.309 13.2488 7.309h-3.1772c-1.1964 0-2.1663.9442-2.1663 2.1089v8.208z";
|
||||
private const string MisskeyPath = "M8.91076 16.8915c-1.03957.0038-1.93213-.6294-2.35267-1.366-.22516-.3217-.66989-.4364-.6761 0v2.0148c0 .8094-.29152 1.5097-.87581 2.1002-.56755.573-1.25977.8595-2.0779.8595-.80014 0-1.49298-.2865-2.07727-.8601C.28408 19.05 0 18.3497 0 17.5403V6.45968c0-.62378.17553-1.18863.52599-1.69455.36657-.52284.83426-.88582 1.4018-1.08769a2.84574 2.84574 0 0 1 1.00049-.17742c.90125 0 1.65239.35421 2.25281 1.06262l2.99713 3.51572c.06699.05016.263.43696.73192.43696.47016 0 .6916-.3868.75796-.43758l2.9717-3.5151c.6178-.70841 1.377-1.06262 2.2782-1.06262.3337 0 .6675.05893 1.0012.17742.5669.20187 1.0259.56422 1.377 1.08769.3665.50592.5501 1.07077.5501 1.69455V17.5403c0 .8094-.2915 1.5097-.8758 2.1002-.5675.573-1.2604.8595-2.0779.8595-.8008 0-1.493-.2865-2.0779-.8601-.5669-.5899-.8504-1.2902-.8504-2.0996v-2.0148c-.0496-.5499-.5303-.2032-.7009 0-.4503.8431-1.31369 1.3616-2.35264 1.366ZM21.447 8.60998c-.7009 0-1.3015-.24449-1.8019-.73348-.4838-.50571-.7257-1.11277-.7257-1.82118s.2419-1.30711.7257-1.79611c.5004-.50571 1.101-.75856 1.8019-.75856.7009 0 1.3017.25285 1.8025.75856.5003.489.7505 1.0877.7505 1.79611 0 .70841-.2502 1.31547-.7505 1.82118-.5008.48899-1.1016.73348-1.8025.73348Zm.0248.50655c.7009 0 1.2935.25285 1.7777.75856.5003.50571.7505 1.11301.7505 1.82181v6.2484c0 .7084-.2502 1.3155-.7505 1.8212-.4838.489-1.0764.7335-1.7777.7335-.7005 0-1.3011-.2445-1.8019-.7335-.5003-.5057-.7505-1.1128-.7505-1.8212v-6.2484c0-.7084.2502-1.3157.7505-1.82181.5004-.50571 1.101-.75856 1.8019-.75856Z";
|
||||
private const string LemmyPath = "M2.9595 4.2228a3.9132 3.9132 0 0 0-.332.019c-.8781.1012-1.67.5699-2.155 1.3862-.475.8-.5922 1.6809-.35 2.4971.2421.8162.8297 1.5575 1.6982 2.1449.0053.0035.0106.0076.0163.0114.746.4498 1.492.7431 2.2877.8994-.02.3318-.0272.6689-.006 1.0181.0634 1.0432.4368 2.0006.996 2.8492l-2.0061.8189a.4163.4163 0 0 0-.2276.2239.416.416 0 0 0 .0879.455.415.415 0 0 0 .2941.1231.4156.4156 0 0 0 .1595-.0312l2.2093-.9035c.408.4859.8695.9315 1.3723 1.318.0196.0151.0407.0264.0603.0423l-1.2918 1.7103a.416.416 0 0 0 .664.501l1.314-1.7385c.7185.4548 1.4782.7927 2.2294 1.0242.3833.7209 1.1379 1.1871 2.0202 1.1871.8907 0 1.6442-.501 2.0242-1.2072.744-.2347 1.4959-.5729 2.2073-1.0262l1.332 1.7606a.4157.4157 0 0 0 .7439-.1936.4165.4165 0 0 0-.0799-.3074l-1.3099-1.7345c.0083-.0075.0178-.0113.0261-.0188.4968-.3803.9549-.8175 1.3622-1.2939l2.155.8794a.4156.4156 0 0 0 .5412-.2276.4151.4151 0 0 0-.2273-.5432l-1.9438-.7928c.577-.8538.9697-1.8183 1.0504-2.8693.0268-.3507.0242-.6914.0079-1.0262.7905-.1572 1.5321-.4502 2.2737-.8974.0053-.0033.011-.0076.0163-.0113.8684-.5874 1.456-1.3287 1.6982-2.145.2421-.8161.125-1.697-.3501-2.497-.4849-.8163-1.2768-1.2852-2.155-1.3863a3.2175 3.2175 0 0 0-.332-.0189c-.7852-.0151-1.6231.229-2.4286.6942-.5926.342-1.1252.867-1.5433 1.4387-1.1699-.6703-2.6923-1.0476-4.5635-1.0785a15.5768 15.5768 0 0 0-.5111 0c-2.085.034-3.7537.43-5.0142 1.1449-.0033-.0038-.0045-.0114-.008-.0152-.4233-.5916-.973-1.1365-1.5835-1.489-.8055-.465-1.6434-.7083-2.4286-.6941Zm.2858.7365c.5568.042 1.1696.2358 1.7787.5875.485.28.9757.7554 1.346 1.2696a5.6875 5.6875 0 0 0-.4969.4085c-.9201.8516-1.4615 1.9597-1.668 3.2335-.6809-.1402-1.3183-.3945-1.984-.7948-.7553-.5128-1.2159-1.1225-1.4004-1.7445-.1851-.624-.1074-1.2712.2776-1.9196.3743-.63.9275-.9534 1.6118-1.0322a2.796 2.796 0 0 1 .5352-.0076Zm17.5094 0a2.797 2.797 0 0 1 .5353.0075c.6842.0786 1.2374.4021 1.6117 1.0322.385.6484.4627 1.2957.2776 1.9196-.1845.622-.645 1.2317-1.4004 1.7445-.6578.3955-1.2881.6472-1.9598.7888-.1942-1.2968-.7375-2.4338-1.666-3.302a5.5639 5.5639 0 0 0-.4709-.3923c.3645-.49.8287-.9428 1.2938-1.2113.6091-.3515 1.2219-.5454 1.7787-.5875ZM12.006 6.0036a14.832 14.832 0 0 1 .487 0c2.3901.0393 4.0848.67 5.1631 1.678 1.1501 1.0754 1.6423 2.6006 1.499 4.467-.1311 1.7079-1.2203 3.2281-2.652 4.324-.694.5313-1.4626.9354-2.2254 1.2294.0031-.0453.014-.0888.014-.1349.0029-1.1964-.9313-2.2133-2.2918-2.2133-1.3606 0-2.3222 1.0154-2.2918 2.2213.0013.0507.014.0972.0181.1471-.781-.2933-1.5696-.7013-2.2777-1.2456-1.4239-1.0945-2.4997-2.6129-2.6037-4.322-.1129-1.8567.3778-3.3382 1.5212-4.3965C7.5094 6.7 9.352 6.047 12.006 6.0036Zm-3.6419 6.8291c-.6053 0-1.0966.4903-1.0966 1.0966 0 .6063.4913 1.0986 1.0966 1.0986s1.0966-.4923 1.0966-1.0986c0-.6063-.4913-1.0966-1.0966-1.0966zm7.2819.0113c-.5998 0-1.0866.4859-1.0866 1.0866s.4868 1.0885 1.0866 1.0885c.5997 0 1.0865-.4878 1.0865-1.0885s-.4868-1.0866-1.0865-1.0866zM12 16.0835c1.0237 0 1.5654.638 1.5634 1.4829-.0018.7849-.6723 1.485-1.5634 1.485-.9167 0-1.54-.5629-1.5634-1.493-.0212-.8347.5397-1.4749 1.5634-1.4749Z";
|
||||
private const string PleromaPath = "M6.36 0A1.868 1.868 0 004.49 1.868V24h5.964V0zm7.113 0v12h4.168a1.868 1.868 0 001.868-1.868V0zm0 18.036V24h4.168a1.868 1.868 0 001.868-1.868v-4.096Z";
|
||||
private const string FirefishPath = "M16.771 0c-.68-.016-1.342.507-1.342 1.304V7.27c0 .719.582 1.301 1.3 1.301h5.967c1.16 0 1.74-1.401.92-2.22L17.65.383a1.275 1.275 0 0 0-.879-.383ZM6.573.106c-.672-.017-1.326.5-1.326 1.287v5.892c0 .71.575 1.285 1.285 1.285h5.892c1.145 0 1.718-1.384.908-2.194L7.44.484a1.259 1.259 0 0 0-.867-.379ZM1.286 10.287c-.71 0-1.286.576-1.286 1.286v11.142C0 23.425.576 24 1.286 24h11.143c.71 0 1.285-.575 1.285-1.285V11.573c0-.71-.575-1.286-1.285-1.286zm15.485 0c-.68-.017-1.342.507-1.342 1.304v5.966c0 .718.582 1.3 1.3 1.3h5.967c1.16 0 1.74-1.4.92-2.22L17.65 10.67a1.275 1.275 0 0 0-.879-.384zM3.43 17.144a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428zm4.285 0a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428z";
|
||||
|
||||
/// <summary>Generic fediverse glyph (honeycomb) for instances whose software isn't in our icon set.</summary>
|
||||
public const string FediverseIconData = "M12 4A1.3 1.3 0 1 0 12 6.6 1.3 1.3 0 1 0 12 4ZM12 10.7A1.3 1.3 0 1 0 12 13.3 1.3 1.3 0 1 0 12 10.7ZM12 17.4A1.3 1.3 0 1 0 12 20 1.3 1.3 0 1 0 12 17.4ZM5.4 6.05A1.3 1.3 0 1 0 5.4 8.65 1.3 1.3 0 1 0 5.4 6.05ZM5.4 15.35A1.3 1.3 0 1 0 5.4 17.95 1.3 1.3 0 1 0 5.4 15.35ZM18.6 6.05A1.3 1.3 0 1 0 18.6 8.65 1.3 1.3 0 1 0 18.6 6.05ZM18.6 15.35A1.3 1.3 0 1 0 18.6 17.95 1.3 1.3 0 1 0 18.6 15.35Z";
|
||||
|
||||
public static string LogoDataFor(SocialService service) => service switch
|
||||
{
|
||||
SocialService.YouTube => "YT",
|
||||
SocialService.Twitch => "TW",
|
||||
SocialService.X => "X",
|
||||
SocialService.Instagram => "IG",
|
||||
SocialService.TikTok => "TT",
|
||||
SocialService.Facebook => "FB",
|
||||
SocialService.Discord => "DC",
|
||||
SocialService.Kick => "K",
|
||||
SocialService.Threads => "TH",
|
||||
SocialService.Bluesky => "BS",
|
||||
SocialService.GitHub => "GH",
|
||||
SocialService.LinkedIn => "LI",
|
||||
SocialService.Pinterest => "PN",
|
||||
SocialService.Snapchat => "SC",
|
||||
SocialService.Reddit => "RD",
|
||||
SocialService.WhatsApp => "WA",
|
||||
SocialService.Telegram => "TG",
|
||||
SocialService.Link => "L",
|
||||
_ => "★",
|
||||
SocialService.YouTube => YouTubePath,
|
||||
SocialService.Twitch => TwitchPath,
|
||||
SocialService.X => XPath,
|
||||
SocialService.Instagram => InstagramPath,
|
||||
SocialService.TikTok => TikTokPath,
|
||||
SocialService.Facebook => FacebookPath,
|
||||
SocialService.Discord => DiscordPath,
|
||||
SocialService.Kick => KickPath,
|
||||
SocialService.Threads => ThreadsPath,
|
||||
SocialService.Bluesky => BlueskyPath,
|
||||
SocialService.GitHub => GitHubPath,
|
||||
SocialService.LinkedIn => LinkedInPath,
|
||||
SocialService.Pinterest => PinterestPath,
|
||||
SocialService.Snapchat => SnapchatPath,
|
||||
SocialService.Reddit => RedditPath,
|
||||
SocialService.WhatsApp => WhatsAppPath,
|
||||
SocialService.Telegram => TelegramPath,
|
||||
SocialService.Fediverse => FediverseIconData,
|
||||
_ => LinkIconData,
|
||||
};
|
||||
|
||||
public static string ColorFor(SocialService service) => service switch
|
||||
/// <summary>
|
||||
/// The logo for a fediverse instance given its nodeinfo software name
|
||||
/// (e.g. "peertube", "mastodon"). Unknown software gets the generic
|
||||
/// fediverse glyph.
|
||||
/// </summary>
|
||||
public static string LogoDataForFediverse(string? software) => software?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
SocialService.YouTube => "#FF0000",
|
||||
SocialService.Twitch => "#9146FF",
|
||||
SocialService.X => "#000000",
|
||||
SocialService.Instagram => "#E4405F",
|
||||
SocialService.TikTok => "#010101",
|
||||
SocialService.Facebook => "#1877F2",
|
||||
SocialService.Discord => "#5865F2",
|
||||
SocialService.Kick => "#53FC18",
|
||||
SocialService.Threads => "#010101",
|
||||
SocialService.Bluesky => "#0085FF",
|
||||
SocialService.GitHub => "#181717",
|
||||
SocialService.LinkedIn => "#0A66C2",
|
||||
SocialService.Pinterest => "#BD081C",
|
||||
SocialService.Snapchat => "#FFFC00",
|
||||
SocialService.Reddit => "#FF4500",
|
||||
SocialService.WhatsApp => "#25D366",
|
||||
SocialService.Telegram => "#26A5E4",
|
||||
_ => "#e94560",
|
||||
"mastodon" => MastodonPath,
|
||||
"peertube" => PeerTubePath,
|
||||
"pixelfed" => PixelfedPath,
|
||||
"misskey" => MisskeyPath,
|
||||
"lemmy" => LemmyPath,
|
||||
"pleroma" => PleromaPath,
|
||||
"firefish" => FirefishPath,
|
||||
_ => FediverseIconData,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The canonical profile URL for a service handle. Fediverse handles
|
||||
/// (@user@domain) are recognized first — their identity carries its own host.
|
||||
/// </summary>
|
||||
public static string CanonicalUrlFor(SocialService service, string handle)
|
||||
{
|
||||
var h = handle.Trim().TrimStart('@');
|
||||
var input = handle.Trim();
|
||||
if (TryParseFediverse(input, out var fedUser, out var fedDomain))
|
||||
return $"https://{fedDomain}/@{fedUser}";
|
||||
var h = input.TrimStart('@');
|
||||
return service switch
|
||||
{
|
||||
SocialService.YouTube => $"https://www.youtube.com/@{h}",
|
||||
@@ -136,4 +197,69 @@ public static class SocialServiceIcons
|
||||
_ => $"https://{h}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detects the social service from a URL or fediverse handle. Supports
|
||||
/// full URLs (https://x.com/user), domain-only (x.com/user), and fediverse
|
||||
/// handles (@user@instance.tube). Returns Link/Website for unknown domains
|
||||
/// or bare handles — the caller can then ask which service it is.
|
||||
/// </summary>
|
||||
public static (SocialService service, string handle, string url) DetectService(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// Fediverse handle: @user@domain — the full handle IS the identity.
|
||||
if (TryParseFediverse(trimmed, out var fedUser, out var fedDomain))
|
||||
return (SocialService.Fediverse, trimmed, $"https://{fedDomain}/@{fedUser}");
|
||||
|
||||
// Strip protocol for domain parsing
|
||||
var url = trimmed;
|
||||
if (!trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
url = $"https://{trimmed}";
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var domain = uri.Host.ToLowerInvariant();
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
|
||||
return domain switch
|
||||
{
|
||||
"youtube.com" or "www.youtube.com" or "youtu.be" => (SocialService.YouTube, path, url),
|
||||
"twitch.tv" or "www.twitch.tv" => (SocialService.Twitch, path, url),
|
||||
"x.com" or "twitter.com" => (SocialService.X, path, url),
|
||||
"instagram.com" or "www.instagram.com" => (SocialService.Instagram, path, url),
|
||||
"tiktok.com" or "www.tiktok.com" => (SocialService.TikTok, path, url),
|
||||
"facebook.com" or "www.facebook.com" or "fb.com" => (SocialService.Facebook, path, url),
|
||||
"discord.gg" or "discord.com" => (SocialService.Discord, path, url),
|
||||
"kick.com" => (SocialService.Kick, path, url),
|
||||
"threads.net" or "www.threads.net" => (SocialService.Threads, path, url),
|
||||
"bsky.app" => (SocialService.Bluesky, path, url),
|
||||
"github.com" => (SocialService.GitHub, path, url),
|
||||
"linkedin.com" or "www.linkedin.com" => (SocialService.LinkedIn, path, url),
|
||||
"pinterest.com" or "www.pinterest.com" => (SocialService.Pinterest, path, url),
|
||||
"snapchat.com" => (SocialService.Snapchat, path, url),
|
||||
"reddit.com" or "www.reddit.com" => (SocialService.Reddit, path, url),
|
||||
"wa.me" or "whatsapp.com" => (SocialService.WhatsApp, path, url),
|
||||
"t.me" => (SocialService.Telegram, path, url),
|
||||
_ => (SocialService.Website, path, url),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
|
||||
public static bool TryParseFediverse(string input, out string user, out string domain)
|
||||
{
|
||||
user = domain = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
|
||||
var secondAt = input.IndexOf('@', 1);
|
||||
if (secondAt <= 1 || secondAt == input.Length - 1) return false;
|
||||
user = input[1..secondAt];
|
||||
domain = input[(secondAt + 1)..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -13,8 +13,9 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||
| `Socials.cs` | **Social bar** (global resource, per-scene presence): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website), `SocialEntry` (Service/Handle/ProfileUrl + Initials/AccentColor), `SocialsConfig` (Entries + BarPosition Top/Bottom + BarJustify LCR), `SocialServiceIcons` (canonical URL builder + initials/color per service) |
|
||||
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
|
||||
|
||||
@@ -45,12 +45,13 @@ dotnet run
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite) |
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
|
||||
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
|
||||
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
|
||||
|
||||
## Docs (memory map)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
|
||||
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
|
||||
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
|
||||
/// does not flicker.
|
||||
/// </summary>
|
||||
public sealed class AudioLevelMeter
|
||||
{
|
||||
private const float Smoothing = 0.2f;
|
||||
private float _level;
|
||||
|
||||
public float Level => _level;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
|
||||
/// spread linearly across 0..1. Linear RMS of real speech or game audio is
|
||||
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking
|
||||
/// dead; the log scale makes typical levels occupy the bar.
|
||||
/// </summary>
|
||||
public static float ToDisplay(float linear)
|
||||
{
|
||||
if (linear <= 0.001f)
|
||||
return 0f;
|
||||
var db = 20f * MathF.Log10(linear);
|
||||
return Math.Clamp(1f + db / 60f, 0f, 1f);
|
||||
}
|
||||
|
||||
public float Push(AudioSample sample)
|
||||
{
|
||||
if (sample.Samples.Length == 0)
|
||||
return _level;
|
||||
|
||||
double sumSquares = 0;
|
||||
var count = 0;
|
||||
foreach (var value in sample.Samples)
|
||||
{
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
|
||||
var rms = (float)Math.Sqrt(sumSquares / count);
|
||||
_level = _level * (1 - Smoothing) + rms * Smoothing;
|
||||
return _level;
|
||||
}
|
||||
|
||||
public void Reset() => _level = 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
|
||||
/// the footer meters. Capture runs for the app's lifetime (started once at
|
||||
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
|
||||
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
|
||||
/// The mixer surfaces mic connection state (Connected/Failed) for the status
|
||||
/// dot and can restart the mic source mid-session when a device is re-picked.
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
private readonly IAudioSource _mic;
|
||||
private readonly IAudioSource _loopback;
|
||||
private readonly AudioLevelMeter _meter;
|
||||
private readonly AudioLevelMeter _loopbackMeter;
|
||||
private readonly Action<string>? _log;
|
||||
private bool _started;
|
||||
|
||||
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
_meter = new AudioLevelMeter();
|
||||
_loopbackMeter = new AudioLevelMeter();
|
||||
_log = log;
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
_loopback.SampleReady += OnLoopbackSample;
|
||||
_mic.Failed += OnMicFailed;
|
||||
_loopback.Failed += OnLoopbackFailed;
|
||||
}
|
||||
|
||||
/// <summary>Current smoothed mic level (0..1).</summary>
|
||||
public float MicLevel => _meter.Level;
|
||||
|
||||
/// <summary>Current smoothed desktop/game level (0..1).</summary>
|
||||
public float LoopbackLevel => _loopbackMeter.Level;
|
||||
|
||||
/// <summary>Raised whenever the smoothed mic level changes.</summary>
|
||||
public event Action<float>? MicLevelChanged;
|
||||
|
||||
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
|
||||
public event Action<float>? LoopbackLevelChanged;
|
||||
|
||||
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
|
||||
public event Action? MicConnected;
|
||||
|
||||
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
|
||||
public event Action<Exception>? MicFailed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
|
||||
_started = true;
|
||||
_meter.Reset();
|
||||
_loopback.Start();
|
||||
_mic.Start();
|
||||
}
|
||||
|
||||
/// <summary>Swaps the mic source without touching loopback — used when the
|
||||
/// creator picks a different device mid-session. The level resets and the
|
||||
/// new source raises <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
|
||||
public void RestartMic()
|
||||
{
|
||||
_mic.Stop();
|
||||
_meter.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
_mic.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
_mic.Stop();
|
||||
_loopback.Stop();
|
||||
_meter.Reset();
|
||||
_loopbackMeter.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
LoopbackLevelChanged?.Invoke(0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_mic.Started -= OnMicStarted;
|
||||
_mic.SampleReady -= OnMicSample;
|
||||
_loopback.SampleReady -= OnLoopbackSample;
|
||||
_mic.Failed -= OnMicFailed;
|
||||
_loopback.Failed -= OnLoopbackFailed;
|
||||
_mic.Dispose();
|
||||
_loopback.Dispose();
|
||||
}
|
||||
|
||||
private void OnMicStarted()
|
||||
{
|
||||
MicConnected?.Invoke();
|
||||
}
|
||||
|
||||
private void OnMicSample(AudioSample sample)
|
||||
{
|
||||
// Push unconditionally: the ?. on the event would otherwise skip the
|
||||
// argument (and the meter update) when nothing is subscribed yet.
|
||||
var level = _meter.Push(sample);
|
||||
MicLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
private void OnLoopbackSample(AudioSample sample)
|
||||
{
|
||||
var level = _loopbackMeter.Push(sample);
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||
MicLevelChanged?.Invoke(0);
|
||||
MicFailed?.Invoke(ex);
|
||||
}
|
||||
|
||||
private void OnLoopbackFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A chunk of interleaved PCM float samples (-1..1) with its format. This is
|
||||
/// the unit every <see cref="IAudioSource"/> produces and the
|
||||
/// <see cref="AudioMixer"/> consumes (TASK 4 ship step 4).
|
||||
/// </summary>
|
||||
public sealed class AudioSample
|
||||
{
|
||||
public float[] Samples { get; }
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
|
||||
public AudioSample(float[] samples, int sampleRate, int channels)
|
||||
{
|
||||
Samples = samples;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
|
||||
/// chunks. The default implementations wrap NAudio's WASAPI capture (mic) /
|
||||
/// loopback (desktop/game); the mixer and the tests consume this interface,
|
||||
/// never NAudio directly. Capture now runs for the app's lifetime so the
|
||||
/// footer meters stay live in preview — the mixer owns start/stop.
|
||||
/// </summary>
|
||||
public interface IAudioSource : IDisposable
|
||||
{
|
||||
/// <summary>Starts capturing. Safe to call only once per Stop.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
|
||||
void Stop();
|
||||
|
||||
/// <summary>Raises once capture is live (right after recording starts).
|
||||
/// Never raised when Start fails — <see cref="Failed"/> fires instead.</summary>
|
||||
event Action? Started;
|
||||
|
||||
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
|
||||
event Action<AudioSample>? SampleReady;
|
||||
|
||||
/// <summary>Raises when capture dies or fails to start (e.g. no device).</summary>
|
||||
event Action<Exception>? Failed;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the user's desktop/game audio (TASK 4 ship step 4) via WASAPI
|
||||
/// loopback on the default render device. Runs for the app's lifetime so the
|
||||
/// game audio bar stays live in preview.
|
||||
/// </summary>
|
||||
public sealed class WasapiLoopbackAudioSource : IAudioSource
|
||||
{
|
||||
private WasapiLoopbackCapture? _capture;
|
||||
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
_capture = new WasapiLoopbackCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
Started?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
|
||||
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
|
||||
/// persists DisplayName), falling back to the default capture endpoint.
|
||||
/// </summary>
|
||||
public sealed class WasapiMicAudioSource : IAudioSource
|
||||
{
|
||||
private readonly Func<string?> _micNameProvider;
|
||||
private WasapiCapture? _capture;
|
||||
|
||||
/// <param name="micNameProvider">Returns the current mic DisplayName; read
|
||||
/// at each Start so a device picked mid-session takes effect immediately
|
||||
/// (the mixer restarts the mic on pick).</param>
|
||||
public WasapiMicAudioSource(Func<string?> micNameProvider)
|
||||
{
|
||||
_micNameProvider = micNameProvider;
|
||||
}
|
||||
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
var device = ResolveDevice();
|
||||
_capture = device != null ? new WasapiCapture(device) : new WasapiCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
Started?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private MMDevice? ResolveDevice()
|
||||
{
|
||||
var micName = _micNameProvider();
|
||||
if (string.IsNullOrWhiteSpace(micName))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
foreach (var endpoint in enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active))
|
||||
{
|
||||
if (string.Equals(endpoint.FriendlyName, micName, StringComparison.OrdinalIgnoreCase))
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NAudio.Dmo;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Converts NAudio's raw WASAPI capture buffers (byte[], whatever bit depth the
|
||||
/// device's mix format reports) into interleaved PCM float samples. Pure —
|
||||
/// unit-tested; the two WASAPI sources share it.
|
||||
/// </summary>
|
||||
public static class WaveToFloat
|
||||
{
|
||||
public static float[] Convert(byte[] buffer, int bytesRecorded, WaveFormat format)
|
||||
{
|
||||
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
if (format is WaveFormatExtensible extensible
|
||||
&& extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
}
|
||||
|
||||
private static float[] ConvertIeeeFloat(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 4;
|
||||
var result = new float[count];
|
||||
Buffer.BlockCopy(buffer, 0, result, 0, count * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float[] ConvertPcm16(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 2;
|
||||
var result = new float[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
result[i] = BitConverter.ToInt16(buffer, i * 2) / 32768f;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,16 @@ public sealed class SceneCompositor
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash. Transparent regions read opaque black.
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
Scene scene,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
VideoFrame? flashFrame,
|
||||
CompositorOptions options)
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
@@ -79,7 +82,10 @@ public sealed class SceneCompositor
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
BlitFlash(buffer, cropW, cropH, options, flashFrame);
|
||||
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
|
||||
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
@@ -166,17 +172,20 @@ public sealed class SceneCompositor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>1:1 copy of the master-sized branding flash, cropped to the active source rect.</summary>
|
||||
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash)
|
||||
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
|
||||
private static void BlitOverlay(
|
||||
byte[] dst, int dstW, int dstH, CompositorOptions options,
|
||||
VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
for (var y = 0; y < dstH; y++)
|
||||
{
|
||||
for (var x = 0; x < dstW; x++)
|
||||
{
|
||||
var sx = x + options.SourceRectX;
|
||||
var sy = y + options.SourceRectY;
|
||||
if (sx >= flash.Width || sy >= flash.Height) continue;
|
||||
var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy);
|
||||
var sx = x + options.SourceRectX - sx0;
|
||||
var sy = y + options.SourceRectY - sy0;
|
||||
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
|
||||
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes the global social bar into a transparent BGRA8 strip the output
|
||||
/// compositor overlays onto the master frame (top or bottom edge). Replicates the
|
||||
/// preview's bar DataTemplate in code — one Path (logo) + one TextBlock (handle)
|
||||
/// per entry, white on transparent, green glow — then software-renders it via
|
||||
/// <c>RenderTargetBitmap</c> and converts Pbgra32 (premultiplied) to straight-alpha
|
||||
/// BGRA for the compositor. WPF glue like <see cref="StaticPixelCache"/>: the
|
||||
/// compositor core itself stays pure byte-math. Renders on the UI thread only;
|
||||
/// the returned frame is immutable afterwards, so the frame pump may read it from
|
||||
/// any thread.
|
||||
/// </summary>
|
||||
public static class SocialBarRenderer
|
||||
{
|
||||
/// <summary>Icon size + the preview's 8px top/bottom margins.</summary>
|
||||
private const int ContentHeight = 40;
|
||||
|
||||
/// <summary>Room for the green DropShadowEffect blur to bleed past the content.</summary>
|
||||
private const int GlowPad = 24;
|
||||
|
||||
/// <summary>Master-frame width — the bar spans the full 1920px output.</summary>
|
||||
public const int Width = 1920;
|
||||
|
||||
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
||||
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries)
|
||||
{
|
||||
var list = entries?.Where(e => e != null).ToList();
|
||||
if (list == null || list.Count == 0) return null;
|
||||
|
||||
var height = ContentHeight + GlowPad * 2;
|
||||
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
foreach (var entry in list)
|
||||
{
|
||||
var item = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(0, 0, 20, 0),
|
||||
};
|
||||
item.Children.Add(new Path
|
||||
{
|
||||
Data = Geometry.Parse(entry.LogoData),
|
||||
Fill = Brushes.White,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Stretch = Stretch.Uniform,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
item.Children.Add(new TextBlock
|
||||
{
|
||||
Text = entry.Handle,
|
||||
Foreground = Brushes.White,
|
||||
FontSize = 14,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
});
|
||||
row.Children.Add(item);
|
||||
}
|
||||
|
||||
// The glow is baked in so the output bar matches the preview's green halo.
|
||||
var container = new Grid
|
||||
{
|
||||
Width = Width,
|
||||
Height = height,
|
||||
Background = Brushes.Transparent,
|
||||
Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 },
|
||||
};
|
||||
var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) };
|
||||
centered.Children.Add(row);
|
||||
container.Children.Add(centered);
|
||||
|
||||
container.Measure(new Size(Width, height));
|
||||
container.Arrange(new Rect(0, 0, Width, height));
|
||||
|
||||
var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(container);
|
||||
|
||||
var pixels = new byte[Width * height * 4];
|
||||
bitmap.CopyPixels(pixels, Width * 4, 0);
|
||||
|
||||
// Pbgra32 is premultiplied — unpremultiply so the compositor's straight-alpha
|
||||
// source-over blend doesn't darken the logo edges with a halo.
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
if (a == 0 || a == 255) continue;
|
||||
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
|
||||
return new VideoFrame(Width, height, pixels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
|
||||
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
|
||||
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/<key></c>).
|
||||
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
|
||||
/// placeholder track until the WASAPI capture step replaces the input.
|
||||
/// </summary>
|
||||
public sealed class EncoderOptions
|
||||
{
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
public int Fps { get; init; } = 60;
|
||||
public int BitrateKbps { get; init; } = 8000;
|
||||
|
||||
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
|
||||
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
|
||||
public string? VideoEncoder { get; init; }
|
||||
|
||||
public int AudioSampleRate { get; init; } = 48000;
|
||||
public int AudioChannels { get; init; } = 2;
|
||||
|
||||
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
|
||||
public int GopSize => Fps * 4;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
|
||||
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
|
||||
/// muxing to the ingestion URL. Pure — the encoder just starts
|
||||
/// <c>ffmpeg.exe [Build(...)]</c>.
|
||||
/// </summary>
|
||||
public static class FfmpegArgs
|
||||
{
|
||||
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
return
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel", "info",
|
||||
"-stats",
|
||||
"-stats_period", "0.5",
|
||||
"-re",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "lavfi",
|
||||
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
|
||||
"-c:v", videoEncoder,
|
||||
"-b:v", $"{options.BitrateKbps}k",
|
||||
"-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k",
|
||||
"-g", gop.ToString(),
|
||||
"-keyint_min", gop.ToString(),
|
||||
"-sc_threshold", "0",
|
||||
"-bf", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", options.AudioSampleRate.ToString(),
|
||||
"-ac", options.AudioChannels.ToString(),
|
||||
"-f", "flv",
|
||||
options.RtmpUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Diagnostics;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IFfmpegEncoder"/>: spawns <c>ffmpeg.exe</c> (resolved via
|
||||
/// <see cref="IFfmpegLocator"/>), feeds raw BGRA frames into stdin, and parses the
|
||||
/// <c>-stats</c> progress lines into <see cref="StreamHealth"/>. Encoder choice is
|
||||
/// probed from the binary's <c>-encoders</c> listing (hardware NVENC/QSV/AMF first,
|
||||
/// OpenH264 software fallback — never libx264, see the license posture) unless
|
||||
/// <see cref="EncoderOptions.VideoEncoder"/> forces one.
|
||||
///
|
||||
/// Graceful stop = close stdin (EOF) → ffmpeg finalizes the FLV and exits by itself;
|
||||
/// a watchdogs kill fires only if it hasn't exited shortly after EOF.
|
||||
/// </summary>
|
||||
public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
{
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
public event EventHandler<string>? ProcessFailed;
|
||||
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IEncoderProcess> _processFactory;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IEncoderProcess? _process;
|
||||
private EncoderOptions? _options;
|
||||
private StreamHealth _health = new() { Status = StreamStatus.Offline };
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
public FfmpegEncoder(
|
||||
IFfmpegLocator locator,
|
||||
Func<IEncoderProcess>? processFactory = null)
|
||||
{
|
||||
_locator = locator;
|
||||
_processFactory = processFactory ?? (() => new FfmpegEncoderProcess());
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
|
||||
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsRunning) throw new InvalidOperationException("The encoder is already running.");
|
||||
_options = options;
|
||||
}
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
|
||||
var args = FfmpegArgs.Build(options, encoder);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args) startInfo.ArgumentList.Add(arg);
|
||||
|
||||
IEncoderProcess process;
|
||||
try
|
||||
{
|
||||
process = _processFactory();
|
||||
process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
|
||||
lock (_gate) _options = null;
|
||||
throw;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_process = process;
|
||||
IsRunning = true;
|
||||
_health = new StreamHealth { Status = StreamStatus.Streaming };
|
||||
}
|
||||
|
||||
_stopRequested = false;
|
||||
_stderrLoop = RunStderrLoopAsync(process);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
|
||||
/// (the compositor pump) may race freely. Frames are written as-is — the caller
|
||||
/// paces to capture rate (the compositor's job, ship step 5).
|
||||
/// </summary>
|
||||
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frame == null) throw new ArgumentNullException(nameof(frame));
|
||||
IEncoderProcess? process;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
|
||||
process = _process;
|
||||
}
|
||||
|
||||
var bytes = frame.BgraPixels;
|
||||
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEncoderProcess? process;
|
||||
Task? loop;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
_stopRequested = true;
|
||||
process = _process;
|
||||
loop = _stderrLoop;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
await process!.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
|
||||
process!.Kill();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
|
||||
process!.Kill();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loop != null) await loop.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
IsRunning = false;
|
||||
_health.Status = StreamStatus.Offline;
|
||||
_health.LastError = null;
|
||||
_process = null;
|
||||
_options = null;
|
||||
}
|
||||
|
||||
AppLog.Write($"FFmpeg encoder stopped (exit {process.ExitCode})");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
_process?.Kill();
|
||||
_process?.Dispose();
|
||||
_process = null;
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-encoders");
|
||||
|
||||
using var probe = _processFactory();
|
||||
probe.Start(startInfo);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
var output = await probe.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
return FfmpegEncoderPicker.Pick(output);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
|
||||
return FfmpegEncoderPicker.Preference[^1];
|
||||
}
|
||||
}
|
||||
|
||||
private Task RunStderrLoopAsync(IEncoderProcess process)
|
||||
{
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
|
||||
if (line == null) break;
|
||||
OnStderrLine(process, line);
|
||||
}
|
||||
|
||||
var code = process.ExitCode;
|
||||
var stillRunning = false;
|
||||
lock (_gate) stillRunning = IsRunning;
|
||||
if (stillRunning && !_stopRequested && code != 0)
|
||||
{
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
|
||||
_health.LastError = $"FFmpeg exited with code {code}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnStderrLine(IEncoderProcess process, string line)
|
||||
{
|
||||
var progress = FfmpegProgressParser.TryParse(line);
|
||||
if (progress == null) return;
|
||||
|
||||
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_health.CurrentBitrate = progress.Value.BitrateKbps;
|
||||
_health.FPS = progress.Value.Fps;
|
||||
_health.DroppedFrames = (int)dropped;
|
||||
_health.StreamDuration = progress.Value.Duration;
|
||||
}
|
||||
|
||||
HealthUpdated?.Invoke(this, _health);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the best available H.264 encoder from ffmpeg's <c>-encoders</c> listing,
|
||||
/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then
|
||||
/// the OpenH264 software fallback. Pure parser — the <c>-encoders</c> probe output is
|
||||
/// fetched by the encoder via an <see cref="IEncoderProcess"/> and fed here.
|
||||
/// </summary>
|
||||
public static class FfmpegEncoderPicker
|
||||
{
|
||||
/// <summary>Preference order, best first. All ship in the pinned BtbN lgpl-shared build.</summary>
|
||||
public static readonly string[] Preference =
|
||||
[
|
||||
"h264_nvenc",
|
||||
"h264_qsv",
|
||||
"h264_amf",
|
||||
"libopenh264",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// First <see cref="Preference"/> entry present in the probe output, or the
|
||||
/// software fallback (which the pinned build always contains) if none matched.
|
||||
/// Never returns libx264 — it is GPL and would contaminate the paid product.
|
||||
/// </summary>
|
||||
public static string Pick(string probeOutput)
|
||||
{
|
||||
var available = probeOutput.Split('\n');
|
||||
foreach (var name in Preference)
|
||||
{
|
||||
if (available.Any(line => line.Contains(name, StringComparison.Ordinal)))
|
||||
return name;
|
||||
}
|
||||
return Preference[^1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="IEncoderProcess"/>: a <see cref="Process"/> with all three
|
||||
/// std streams redirected. Constructed by <see cref="FfmpegEncoder"/> for both the
|
||||
/// encoder subprocess and the <c>-encoders</c> probe.
|
||||
/// </summary>
|
||||
public sealed class FfmpegEncoderProcess : IEncoderProcess
|
||||
{
|
||||
private readonly Process _process;
|
||||
|
||||
public FfmpegEncoderProcess() => _process = new Process { EnableRaisingEvents = true };
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
_process.StartInfo = startInfo;
|
||||
_process.Start();
|
||||
}
|
||||
|
||||
public Stream StandardInput => _process.StandardInput.BaseStream;
|
||||
public TextReader StandardOutput => _process.StandardOutput;
|
||||
public TextReader StandardError => _process.StandardError;
|
||||
|
||||
public bool HasExited => _process.HasExited;
|
||||
public int ExitCode => _process.ExitCode;
|
||||
public void Kill()
|
||||
{
|
||||
if (!_process.HasExited) _process.Kill();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
|
||||
=> _process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
public void Dispose() => _process.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>A decoded ffmpeg <c>-stats</c> progress line (pure data).</summary>
|
||||
public readonly record struct FfmpegProgress(
|
||||
long Frame,
|
||||
double Fps,
|
||||
double BitrateKbps,
|
||||
TimeSpan Duration,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser for ffmpeg's periodic <c>frame= fps= size= time= bitrate=</c> stderr
|
||||
/// lines (the <c>-stats</c>/<c>-stats_period</c> output). Unit-tested in isolation
|
||||
/// so the encoder loop stays a thin wire.
|
||||
/// </summary>
|
||||
public static class FfmpegProgressParser
|
||||
{
|
||||
// frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x
|
||||
private static readonly Regex Line = new(
|
||||
@"frame=\s*(?<frame>\d+)\s+fps=\s*(?<fps>[\d.]+).*?"
|
||||
+ @"size=\s*(?<size>\d+)KiB.*?"
|
||||
+ @"time=(?<time>\d{2}):(?<min>\d{2}):(?<sec>[\d.]+).*?"
|
||||
+ @"bitrate=\s*(?<bitrate>[\d.]+)kbits/s",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Returns null for non-progress lines (errors, warnings, banner).</summary>
|
||||
public static FfmpegProgress? TryParse(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||
var m = Line.Match(line);
|
||||
if (!m.Success) return null;
|
||||
|
||||
var frame = long.Parse(m.Groups["frame"].Value);
|
||||
var fps = double.Parse(m.Groups["fps"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var size = long.Parse(m.Groups["size"].Value);
|
||||
var bitrate = double.Parse(m.Groups["bitrate"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var hours = int.Parse(m.Groups["time"].Value);
|
||||
var minutes = int.Parse(m.Groups["min"].Value);
|
||||
var seconds = double.Parse(m.Groups["sec"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var duration = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
|
||||
|
||||
return new FfmpegProgress(frame, fps, bitrate, duration, size * 1024);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
///
|
||||
/// The RTMP URL comes from the options provider: until the live-stream create
|
||||
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
|
||||
/// without actually pushing.
|
||||
/// </summary>
|
||||
public sealed class FramePump : IDisposable
|
||||
{
|
||||
private readonly Func<Scene?> _sceneProvider;
|
||||
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
|
||||
private readonly Func<CompositorOptions> _compositorOptions;
|
||||
private readonly Func<EncoderOptions?> _encoderOptions;
|
||||
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public FramePump(
|
||||
Func<Scene?> sceneProvider,
|
||||
Func<SceneElement, VideoFrame?> frameResolver,
|
||||
Func<CompositorOptions> compositorOptions,
|
||||
Func<EncoderOptions?> encoderOptions,
|
||||
Func<IFfmpegEncoder> encoderFactory,
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
|
||||
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
|
||||
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
}
|
||||
|
||||
IFfmpegEncoder? encoder = null;
|
||||
try
|
||||
{
|
||||
var options = _encoderOptions();
|
||||
if (options == null)
|
||||
{
|
||||
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
|
||||
lock (_gate) _started = false;
|
||||
return;
|
||||
}
|
||||
|
||||
encoder = _encoderFactory();
|
||||
encoder.HealthUpdated += OnHealthUpdated;
|
||||
encoder.ProcessFailed += OnProcessFailed;
|
||||
await encoder.StartAsync(options, cancellationToken);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = encoder;
|
||||
}
|
||||
|
||||
// IsRunning must be true before the loop starts: the loop reads it on
|
||||
// its first iteration, and with a completed-task delay it can run
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: start failed: {ex.Message}");
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
}
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IFfmpegEncoder? encoder;
|
||||
Task? pump;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started && _encoder == null) return;
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
encoder = _encoder;
|
||||
pump = _pumpTask;
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
|
||||
// write stuck on pipe backpressure, otherwise the pump could await forever.
|
||||
if (encoder != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await encoder.StopAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pump != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pump;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = null;
|
||||
_cts = null;
|
||||
_pumpTask = null;
|
||||
}
|
||||
_log?.Invoke("FramePump stopped");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
StopAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
var compositorOptions = _compositorOptions();
|
||||
VideoFrame? socialBarFrame = null;
|
||||
var socialBarTop = 0;
|
||||
if (_socialBar != null)
|
||||
{
|
||||
var (barFrame, position) = _socialBar();
|
||||
socialBarFrame = barFrame;
|
||||
if (barFrame != null)
|
||||
socialBarTop = position == SocialBarPosition.Top
|
||||
? 0
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
var frame = _compositor.Render(
|
||||
scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// normal stop
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failure while the pump is supposed to run (encoder died under us,
|
||||
// scene provider faulted, ...) stops the pump and surfaces once.
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
Failed?.Invoke(this, message);
|
||||
_ = StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Seam around a spawned subprocess (the encoder's ffmpeg and the encoder probe).
|
||||
/// Exposes the redirected stdin (binary frames), stdout (probe listing) and stderr
|
||||
/// (progress lines) plus exit control, so the encoder logic never touches
|
||||
/// <c>System.Diagnostics.Process</c> directly and tests can fake the whole thing.
|
||||
/// </summary>
|
||||
public interface IEncoderProcess : IDisposable
|
||||
{
|
||||
void Start(ProcessStartInfo startInfo);
|
||||
|
||||
/// <summary>Raw binary stdin — the encoder writes BGRA frames here.</summary>
|
||||
Stream StandardInput { get; }
|
||||
|
||||
TextReader StandardOutput { get; }
|
||||
TextReader StandardError { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
int ExitCode { get; }
|
||||
void Kill();
|
||||
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The live encoder seam (TASK 4 ship step 3): start an FFmpeg subprocess that
|
||||
/// encodes raw BGRA frames from stdin and pushes FLV to an RTMP ingestion URL,
|
||||
/// raising parsed health stats from stderr. The frame producer (compositor →
|
||||
/// capture managers, TASK 4 ship step 5) feeds <see cref="SubmitFrameAsync"/> at
|
||||
/// capture rate; this service serializes writes, parses progress, and tears the
|
||||
/// process down gracefully. Constructor-injected <see cref="IFfmpegLocator"/> +
|
||||
/// process factory keep it hermetic (tests fake both).
|
||||
/// </summary>
|
||||
public interface IFfmpegEncoder : IDisposable
|
||||
{
|
||||
/// <summary>Fires on each parsed ffmpeg <c>-stats</c> progress line (~2 Hz).</summary>
|
||||
event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
|
||||
event EventHandler<string>? ProcessFailed;
|
||||
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IGameAudioDetector"/>: samples a full-screen monitor
|
||||
/// provider (the existing <c>IFullScreenDetector</c>) and the live loopback
|
||||
/// level and advances a <see cref="GameAudioHysteresis"/>. The pure transitions
|
||||
/// live in GameAudioHysteresis (unit-tested); this wrapper owns the providers
|
||||
/// and the raised-changed event. WPF-free — the VM owns the poll timer.
|
||||
/// </summary>
|
||||
public sealed class GameAudioDetector : IGameAudioDetector
|
||||
{
|
||||
private const float SoundFloor = 0.005f;
|
||||
|
||||
private readonly Func<int?> _foregroundFullScreenMonitorProvider;
|
||||
private readonly Func<float> _loopbackLevelProvider;
|
||||
private readonly Func<DateTime> _now;
|
||||
private readonly GameAudioHysteresis _hysteresis = new();
|
||||
|
||||
/// <param name="foregroundFullScreenMonitorProvider">Returns the monitor a
|
||||
/// full-screen foreground window covers, or null when windowed/none.</param>
|
||||
/// <param name="loopbackLevelProvider">Current smoothed desktop/game level (0..1).</param>
|
||||
/// <param name="now">Clock for the hysteresis windows; injectable for tests.</param>
|
||||
public GameAudioDetector(
|
||||
Func<int?> foregroundFullScreenMonitorProvider,
|
||||
Func<float> loopbackLevelProvider,
|
||||
Func<DateTime>? now = null)
|
||||
{
|
||||
_foregroundFullScreenMonitorProvider = foregroundFullScreenMonitorProvider;
|
||||
_loopbackLevelProvider = loopbackLevelProvider;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
public bool IsGameAudioActive => _hysteresis.IsActive;
|
||||
|
||||
public event Action<bool>? IsGameAudioActiveChanged;
|
||||
|
||||
public void Poll()
|
||||
{
|
||||
var before = _hysteresis.IsActive;
|
||||
var isFullScreen = _foregroundFullScreenMonitorProvider() != null;
|
||||
var hasSound = _loopbackLevelProvider() > SoundFloor;
|
||||
_hysteresis.Update(isFullScreen, hasSound, _now());
|
||||
if (before != _hysteresis.IsActive)
|
||||
IsGameAudioActiveChanged?.Invoke(_hysteresis.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure show/hide state machine for the game audio bar (TASK 4 game audio).
|
||||
/// SHOW: a full-screen foreground app has been producing desktop audio for at
|
||||
/// least <see cref="ShowAfterSound"/> — "a working game with sound". HIDE: the
|
||||
/// app leaves fullscreen/foreground for <see cref="HideAfterWindowed"/> — the
|
||||
/// game is no longer up in the preview. Silence NEVER hides an active bar; it
|
||||
/// only matters for the initial show. No timers; <see cref="Update"/> is fed by
|
||||
/// the caller with a clock.
|
||||
/// </summary>
|
||||
public sealed class GameAudioHysteresis
|
||||
{
|
||||
private readonly TimeSpan _showAfterSound = TimeSpan.FromMilliseconds(500);
|
||||
private readonly TimeSpan _hideAfterWindowed = TimeSpan.FromSeconds(1);
|
||||
|
||||
private DateTime? _soundSince;
|
||||
private DateTime? _windowedSince;
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public void Update(bool isFullScreen, bool hasSound, DateTime now)
|
||||
{
|
||||
if (isFullScreen)
|
||||
{
|
||||
_windowedSince = null;
|
||||
if (IsActive)
|
||||
return;
|
||||
|
||||
if (!hasSound)
|
||||
{
|
||||
_soundSince = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_soundSince ??= now;
|
||||
if (now - _soundSince >= _showAfterSound)
|
||||
IsActive = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_soundSince = null;
|
||||
if (!IsActive)
|
||||
{
|
||||
_windowedSince = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_windowedSince ??= now;
|
||||
if (now - _windowedSince >= _hideAfterWindowed)
|
||||
{
|
||||
IsActive = false;
|
||||
_windowedSince = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Detects "a working game with sound is up in the preview" (TASK 4 game audio
|
||||
/// bar): becomes active once a full-screen foreground app is producing desktop
|
||||
/// audio, and stays active as long as that full-screen app remains — silence
|
||||
/// never hides the bar, only the game leaving fullscreen/foreground does. Seam
|
||||
/// so the VM and tests never touch Win32 or audio interop directly.
|
||||
/// </summary>
|
||||
public interface IGameAudioDetector
|
||||
{
|
||||
/// <summary>True while the game audio bar should be visible.</summary>
|
||||
bool IsGameAudioActive { get; }
|
||||
|
||||
/// <summary>Raised when the bar should appear or disappear.</summary>
|
||||
event Action<bool>? IsGameAudioActiveChanged;
|
||||
|
||||
/// <summary>Samples the injected providers and advances the state machine.</summary>
|
||||
void Poll();
|
||||
}
|
||||
+53
-8
@@ -135,11 +135,13 @@ public class LayoutStore : IDisposable
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 7;";
|
||||
cmd.CommandText = "PRAGMA user_version = 8;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -360,6 +362,46 @@ public class LayoutStore : IDisposable
|
||||
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();
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
@@ -403,19 +445,19 @@ public class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarJustify FROM Socials LIMIT 1;";
|
||||
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,
|
||||
BarJustify = Enum.TryParse<SocialBarJustify>(reader.GetString(2), out var just) ? just : SocialBarJustify.Center,
|
||||
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 FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
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())
|
||||
@@ -425,6 +467,7 @@ public class LayoutStore : IDisposable
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -717,18 +760,18 @@ public class LayoutStore : IDisposable
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarJustify) VALUES ($id, $pos, $just);";
|
||||
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("$just", socials.BarJustify.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, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $sort)
|
||||
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);
|
||||
@@ -736,6 +779,7 @@ public class LayoutStore : IDisposable
|
||||
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;
|
||||
@@ -746,6 +790,7 @@ public class LayoutStore : IDisposable
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -156,6 +156,15 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>The most recent frame for a capture key, or null before the first
|
||||
/// frame arrives (or if the key has no session). The live compositor reads
|
||||
/// the backdrop from here.</summary>
|
||||
public VideoFrame? GetLatestFrame(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<CaptureSession> sessions;
|
||||
|
||||
+167
-6
@@ -1,4 +1,5 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
@@ -11,11 +12,22 @@ public sealed class SocialLookupResult
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string Error { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
|
||||
public string? FediverseSoftware { get; init; }
|
||||
|
||||
/// <summary>True when the caller canceled the lookup before it finished.</summary>
|
||||
public bool Canceled { get; init; }
|
||||
}
|
||||
|
||||
public interface ISocialValidator
|
||||
{
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl);
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
|
||||
|
||||
/// <summary>Best-effort nodeinfo software name (mastodon/peertube/...) for a
|
||||
/// fediverse domain, or null when it can't be resolved. The load-time heal
|
||||
/// uses this to fix a bar entry whose stored software is missing.</summary>
|
||||
Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,20 +40,52 @@ public interface ISocialValidator
|
||||
public sealed class HttpSocialValidator : ISocialValidator
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly Action<string>? _log;
|
||||
|
||||
public HttpSocialValidator(HttpClient? client = null)
|
||||
public HttpSocialValidator(HttpClient? client = null, Action<string>? log = null)
|
||||
{
|
||||
_client = client ?? new HttpClient();
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl)
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
|
||||
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
var input = handleOrUrl.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return new SocialLookupResult { Error = "Enter a handle or URL." };
|
||||
|
||||
// Fediverse handle (@user@domain): the domain is part of the identity,
|
||||
// so the canonical URL can't be rebuilt from a bare handle. After the
|
||||
// profile validates, ask the instance which software it runs (nodeinfo)
|
||||
// so the caller can show the right service logo.
|
||||
if (input.StartsWith('@') && input.IndexOf('@', 1) > 0)
|
||||
{
|
||||
var at = input.IndexOf('@', 1);
|
||||
var user = input[1..at];
|
||||
var domain = input[(at + 1)..];
|
||||
if (user.Length > 0 && domain.Length > 0)
|
||||
{
|
||||
var url = $"https://{domain}/@{user}";
|
||||
var result = await CheckAsync(service, input, url, ct);
|
||||
if (!result.Success) return result;
|
||||
var software = await TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = result.ProfileUrl,
|
||||
Handle = result.Handle,
|
||||
FediverseSoftware = software,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var handle = input;
|
||||
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
|
||||
@@ -50,23 +94,31 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
handle = uri.AbsolutePath.Trim('/');
|
||||
}
|
||||
|
||||
var url = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||
var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||
return await CheckAsync(service, handle, canonical, ct);
|
||||
}
|
||||
|
||||
private async Task<SocialLookupResult> CheckAsync(SocialService service, string handle, string url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await _client.GetAsync(url, CancellationToken.None);
|
||||
using var resp = await _client.GetAsync(url, ct);
|
||||
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = url,
|
||||
Handle = handle.TrimStart('@'),
|
||||
Handle = handle,
|
||||
};
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
|
||||
};
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
}
|
||||
catch (System.Net.Http.HttpRequestException)
|
||||
{
|
||||
return new SocialLookupResult
|
||||
@@ -79,4 +131,113 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
return new SocialLookupResult { Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first
|
||||
/// nodeinfo link, read `software.name`. If the identity domain is itself a
|
||||
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
|
||||
/// the bare root 302s to the real instance — follow it and ask that host.
|
||||
/// If neither answers, probe well-known subdomains (mastodon.example.com)
|
||||
/// so a landing-page domain still resolves its real instance. The whole
|
||||
/// resolution is bounded by a ~15s budget. Failures return null — validation
|
||||
/// still succeeds, the icon just falls back to the generic fediverse glyph.
|
||||
/// </summary>
|
||||
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
budget.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var software = await FetchSoftwareNameAsync(domain, budget.Token);
|
||||
if (software != null) return software;
|
||||
|
||||
var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
|
||||
if (resolved != null
|
||||
&& !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
software = await FetchSoftwareNameAsync(resolved, budget.Token);
|
||||
if (software != null) return software;
|
||||
}
|
||||
|
||||
foreach (var sub in FediverseSubdomainCandidates)
|
||||
{
|
||||
var host = $"{sub}.{domain}";
|
||||
software = await FetchSoftwareNameAsync(host, budget.Token);
|
||||
if (software != null)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}");
|
||||
return software;
|
||||
}
|
||||
}
|
||||
_log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'");
|
||||
return null;
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return null; // caller checks ct.IsCancellationRequested and reports Canceled
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out");
|
||||
return null;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Common instance subdomains, probed in order when the identity
|
||||
/// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front
|
||||
/// of a self-hosted instance).</summary>
|
||||
private static readonly string[] FediverseSubdomainCandidates =
|
||||
{
|
||||
"mastodon", "social", "fediverse", "tube", "peertube",
|
||||
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
|
||||
};
|
||||
|
||||
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
|
||||
if (nodeInfoUrl == null) return null;
|
||||
using var resp = await _client.GetAsync(nodeInfoUrl, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("software", out var software)
|
||||
&& software.TryGetProperty("name", out var name))
|
||||
return name.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
|
||||
/// and returns the final host — the "default app" behind an identity domain.</summary>
|
||||
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/", ct);
|
||||
return resp.RequestMessage?.RequestUri?.Host;
|
||||
}
|
||||
|
||||
private async Task<string?> FetchNodeInfoUrlAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("links", out var links)) return null;
|
||||
foreach (var link in links.EnumerateArray())
|
||||
{
|
||||
if (!link.TryGetProperty("rel", out var rel)) continue;
|
||||
var relStr = rel.GetString() ?? string.Empty;
|
||||
if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue;
|
||||
if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed))
|
||||
return parsed.IsAbsoluteUri
|
||||
? parsed.AbsoluteUri
|
||||
: new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -27,13 +27,33 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
|
||||
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
|
||||
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
|
||||
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
|
||||
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Constructor takes optional `HttpClient` for tests |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
|
||||
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
|
||||
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
|
||||
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
|
||||
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead |
|
||||
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
|
||||
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
|
||||
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
|
||||
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
|
||||
|
||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<Window x:Class="ytLive.SocialsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Social Media Site Promotion" Height="520" Width="520"
|
||||
Icon="/Assets/llama-logo-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,12"/>
|
||||
|
||||
<!-- Show/hide the bar on the stream -->
|
||||
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
|
||||
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
|
||||
</CheckBox>
|
||||
|
||||
<!-- Sign-in gate: the first slot is always your YouTube channel -->
|
||||
<Border Grid.Row="2" Background="#16213e" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
|
||||
Visibility="{Binding ShowSignInBanner, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Sign in to YouTube first — the first slot is your channel."
|
||||
Foreground="#e0e0e0" FontSize="12" TextWrapping="Wrap"/>
|
||||
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Left"
|
||||
Margin="0,8,0,0" Padding="12,4" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Grid.Row="2" Text="Signing in… open the browser to authorize, then come back here."
|
||||
Foreground="#e94560" FontSize="12" Margin="0,0,0,10"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- The six fixed slots -->
|
||||
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Slots}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="34"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Service icon: logo, lock (Premium) or do-not (empty/invalid) -->
|
||||
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
|
||||
Background="#16213e" HorizontalAlignment="Left" VerticalAlignment="Top">
|
||||
<Grid>
|
||||
<Path Data="{Binding LogoData}" Fill="White" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowLogo, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Data="{Binding LockedIconData}" Fill="#8a93a8" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsLocked, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Data="{Binding DoNotIconData}" Fill="#5a5a6e" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowDoNot, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Handle text / edit box / status -->
|
||||
<Grid Grid.Column="1" Margin="10,0,8,0">
|
||||
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0" FontSize="14"
|
||||
VerticalAlignment="Top" Margin="0,6,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{Binding ShowDisplay, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBox Text="{Binding EditText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBox}" FontSize="13"
|
||||
KeyDown="SocialEdit_KeyDown" LostFocus="SocialEdit_LostFocus"
|
||||
Visibility="{Binding ShowEditBox, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Error}" Foreground="#e94560" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,32,0,0"
|
||||
Visibility="{Binding ShowError, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="Checking…" Foreground="#a0a0b0" FontSize="11" Margin="0,32,0,0"
|
||||
Visibility="{Binding ShowBusy, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Row actions -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Top"
|
||||
Margin="0,3,0,0">
|
||||
<Button Content="Sign in" Click="SignInButton_Click"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,3"
|
||||
FontSize="11" Margin="0,0,6,0"
|
||||
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="+" Click="SlotAdd_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Add a social" FontSize="16" FontWeight="Bold"
|
||||
Visibility="{Binding ShowAddButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="✏️" Click="SlotEdit_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Edit" FontSize="13"
|
||||
Visibility="{Binding ShowEditButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="🗑️" Click="SlotDelete_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Remove" FontSize="12" Foreground="#e94560"
|
||||
Visibility="{Binding ShowDeleteButton, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" IsCancel="True"/>
|
||||
<Button Content="Save" Command="{Binding SaveCommand}" Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class SocialsDialog : Window
|
||||
{
|
||||
private readonly SocialsDialogViewModel _viewModel;
|
||||
|
||||
public SocialsDialog(SocialsDialogViewModel viewModel)
|
||||
{
|
||||
AppLog.Write("SocialsDialog ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
AppLog.Write("SocialsDialog ctor: after InitializeComponent");
|
||||
DataContext = viewModel;
|
||||
_viewModel = viewModel;
|
||||
viewModel.SaveRequested += () => DialogResult = true;
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
Closing += (_, _) => _viewModel.AbortPending();
|
||||
}
|
||||
|
||||
private void SignInButton_Click(object sender, RoutedEventArgs e)
|
||||
=> _viewModel.SignInCommand.Execute(null);
|
||||
|
||||
private void SlotAdd_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: int index })
|
||||
_viewModel.StartEdit(index);
|
||||
}
|
||||
|
||||
private void SlotEdit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: int index })
|
||||
_viewModel.StartEdit(index);
|
||||
}
|
||||
|
||||
private async void SlotDelete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: int index } element) return;
|
||||
var slot = element.DataContext as SocialSlotViewModel;
|
||||
var confirm = index == 0
|
||||
? "Remove your YouTube channel from the bar and sign out of ytLlive?"
|
||||
: slot is { IsFilled: true }
|
||||
? $"Remove {slot.Handle}?"
|
||||
: "Remove this social?";
|
||||
|
||||
if (MessageBox.Show(confirm, "Remove Social", MessageBoxButton.YesNo, MessageBoxImage.Question)
|
||||
== MessageBoxResult.Yes)
|
||||
await _viewModel.DeleteSlotAsync(index);
|
||||
}
|
||||
|
||||
private void SocialEdit_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { DataContext: SocialSlotViewModel slot }) return;
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
_viewModel.ConfirmEdit(slot.Index);
|
||||
}
|
||||
else if (e.Key == Key.Escape)
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
slot.Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialEdit_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SocialSlotViewModel slot })
|
||||
_viewModel.ConfirmEdit(slot.Index);
|
||||
}
|
||||
}
|
||||
@@ -106,15 +106,17 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
||||
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
||||
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule)
|
||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
|
||||
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||
14. ✅ **Social bar** — global resource like the audio meter; user provides handle/URL → app validates by looking up the social page (`HttpSocialValidator`) → adds icon + handle to a single-line horizontal bar (top/bottom, LCR justify). Freemium: YT + 1 other (`FreemiumSocialCap=2`). Premium: unlimited (flag seam, itch.io deferred). [+] adds to scene, [-] removes (`Scene.HasSocialBar`, schema v7). Footer has Social button + [+/-] on the meter line. 85 tests passing.
|
||||
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
||||
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||
16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
|
||||
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
||||
18. ☐ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video
|
||||
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
|
||||
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
|
||||
21. ☐ **Logo + richer in-app About (2026-08-13, queued)** — the ytLlive wordmark in the top bar is clickable and opens the About overlay; make a real **logo** (CC0 per the icon guardrail), and expand the About overlay into the creator's hub: **links to premium unlock + coffee/thanks buys + socials** and a second (or third) **scrolling panel with the full licensing/legal texts on demand** — everything stays IN-APP (no Notepad, no browser tab; the app is standalone). The top-bar About button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer was removed on 2026-08-13 for exactly this reason
|
||||
22. ☐ **Voice filters on the mic channel (2026-08-13, queued — KISS, always on)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Always-on — no UI knobs; the mic stays the creator's single audio control
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
@@ -199,10 +201,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
|
||||
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
|
||||
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
|
||||
3. ☐ **Encoder + RTMP push** — the FFmpeg subprocess: frames via stdin, stderr health parsing, FLV mux + push to the cached reusable stream's ingestion URL
|
||||
4. ☐ **WASAPI audio capture** — loopback (desktop/game) + the picked mic feeding `AudioLevel` so the realtime meter comes alive (req 7)
|
||||
5. ☐ **Frame-pipeline wiring** — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder
|
||||
6. ☐ **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (req 5)
|
||||
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
|
||||
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
|
||||
5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
|
||||
6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
|
||||
7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
|
||||
|
||||
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
|
||||
@@ -336,8 +338,8 @@ provides one.
|
||||
1. `IFfmpegLocator.cs` — the seam.
|
||||
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
|
||||
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
|
||||
output and surfaced via the top-bar **About** button (`MainWindow` code-behind, opens the file in the
|
||||
OS viewer).
|
||||
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
|
||||
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
|
||||
|
||||
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
|
||||
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
|
||||
@@ -355,12 +357,173 @@ parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health sta
|
||||
|
||||
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
|
||||
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
|
||||
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output and is surfaced by a new
|
||||
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**.
|
||||
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
|
||||
guardrails are recorded in `ai.md` — build **0 warnings**.
|
||||
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
|
||||
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
|
||||
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
|
||||
|
||||
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
|
||||
|
||||
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
|
||||
ingestion URL — one battle-tested subprocess doing encode + mux + push + reconnect, the app feeding
|
||||
frames via stdin and parsing stderr for health (req 2).
|
||||
|
||||
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
|
||||
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
|
||||
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
|
||||
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
|
||||
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
|
||||
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
|
||||
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
|
||||
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
|
||||
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
|
||||
whole subprocess (probe + encoder), no real binary.
|
||||
|
||||
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
|
||||
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
|
||||
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
|
||||
on a non-zero unexpected exit.
|
||||
|
||||
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
|
||||
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
|
||||
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
|
||||
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
|
||||
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
|
||||
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
|
||||
|
||||
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
|
||||
|
||||
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
|
||||
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
|
||||
|
||||
**Decisions (locked):**
|
||||
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
|
||||
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
|
||||
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
|
||||
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
|
||||
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
|
||||
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
|
||||
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
|
||||
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
|
||||
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
|
||||
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
|
||||
`StopStream` → `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
|
||||
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
|
||||
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
|
||||
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
|
||||
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
|
||||
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
|
||||
GUID. Trailing partial samples are ignored.
|
||||
|
||||
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
|
||||
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
|
||||
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
|
||||
`MicLevelChanged` → `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
|
||||
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
|
||||
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
|
||||
extensible/truncation) — **139 passing**.
|
||||
|
||||
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
|
||||
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
|
||||
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
|
||||
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
|
||||
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
|
||||
|
||||
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
|
||||
|
||||
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
|
||||
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
|
||||
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
|
||||
|
||||
**Decisions (locked via user Q&A, 2026-08-12):**
|
||||
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
|
||||
WASAPI samples into the encoder's AAC track is its own later step.
|
||||
2. **RTMP URL via a provider seam** — `MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
|
||||
null today (the reusable stream's ingest URL lands with TASK 5); when it yields null the pump logs
|
||||
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
|
||||
|
||||
**Design:**
|
||||
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
|
||||
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
|
||||
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
|
||||
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
|
||||
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
|
||||
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
|
||||
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
|
||||
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
|
||||
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
|
||||
backdrop's live frame for the compositor.
|
||||
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig` → `GetLatestFrame(WebcamId)`;
|
||||
`Source.IsLiveCapture` → `GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
|
||||
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
|
||||
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
|
||||
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
|
||||
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
|
||||
the pump fails while live (minimal — detailed health surfacing is ship step 6).
|
||||
|
||||
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
|
||||
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
|
||||
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
|
||||
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
|
||||
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
|
||||
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
|
||||
|
||||
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
|
||||
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
|
||||
|
||||
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
|
||||
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
|
||||
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
|
||||
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
|
||||
exception is contained (logged + `Failed` + pump stops) rather than crashing.
|
||||
|
||||
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
|
||||
|
||||
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
|
||||
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
|
||||
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition` →
|
||||
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
|
||||
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
|
||||
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
|
||||
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
|
||||
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
|
||||
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
|
||||
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
|
||||
value on a property the binding owns.
|
||||
|
||||
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
|
||||
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
|
||||
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
|
||||
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
|
||||
… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
|
||||
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
|
||||
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
|
||||
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
|
||||
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
|
||||
later re-saves the entry with the icon fixed, the name persists with it.
|
||||
|
||||
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
|
||||
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
|
||||
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
|
||||
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
|
||||
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
|
||||
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
|
||||
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
|
||||
`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
|
||||
load/save/notify) and feeds the seam.
|
||||
|
||||
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
|
||||
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
|
||||
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
|
||||
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
|
||||
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
|
||||
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
|
||||
(`SocialBarSnap`) were removed with the click-toggle supersession.
|
||||
|
||||
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
|
||||
|
||||
---
|
||||
|
||||
## TASK 5 — YouTube Live Stream Management
|
||||
@@ -401,12 +564,12 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v6**)
|
||||
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**)
|
||||
2. ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
|
||||
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
|
||||
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
|
||||
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
|
||||
6. ✅ Schema v1 → v6 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned
|
||||
6. ✅ Schema v1 → v8 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned
|
||||
|
||||
### Design decisions
|
||||
|
||||
@@ -420,16 +583,19 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
|
||||
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
||||
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
||||
add/remove/reorder, and any source transform change; flush on window close.
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder), `Asset` (Id, Hash, Data,
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data,
|
||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
|
||||
`user_version` **6** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
|
||||
`Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
|
||||
round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey` for the live-capture
|
||||
backdrop; v6 = `Scene.HasBackdrop` — the backdrop is **Live-only by policy**
|
||||
(one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||
sources; `EnforceBackdropPolicy` re-normalizes every load). `WindowHandle` stays in-memory
|
||||
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
|
||||
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
|
||||
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
|
||||
`ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
|
||||
v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
|
||||
+ `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` — the backdrop is
|
||||
**Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||
sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle
|
||||
dropped — column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software`
|
||||
fediverse-software column is a column-presence migration (commented v8→v9, no version bump).
|
||||
`WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
6. **Startup** — load the active file; seed the five canonical scenes
|
||||
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
|
||||
button re-adds a missing canonical scene and is hidden once all five are present;
|
||||
@@ -437,6 +603,26 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
|
||||
|
||||
---
|
||||
|
||||
## TASK 7 — UI polish batch (scenes/sources rows, dedup naming, social bar)
|
||||
|
||||
**Goal:** clean up the two side lists and the social bar per gramps's review.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
|
||||
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
|
||||
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
|
||||
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
|
||||
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
|
||||
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
|
||||
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
|
||||
|
||||
---
|
||||
|
||||
## Backlog (future versions)
|
||||
|
||||
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
||||
|
||||
+23
-3
@@ -7,9 +7,10 @@ its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
|
||||
are NOT optional: they attach because this product ships or automates the
|
||||
download of these components.
|
||||
|
||||
If this file changes, update it here AND in the app's About screen (it opens
|
||||
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for
|
||||
the guardrails — the "never do" list is there on purpose.
|
||||
If this file changes, update it here AND in the app's About screen (the About
|
||||
overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
|
||||
ai.md ("Licensing — do not violate") for the guardrails — the "never do" list is
|
||||
there on purpose.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -68,6 +69,25 @@ the guardrails — the "never do" list is there on purpose.
|
||||
License: MIT
|
||||
Home: https://github.com/dotnet/
|
||||
|
||||
8. Simple Icons (bundled SVG logo path data for the social bar, e.g. the
|
||||
mastodon/peertube/pixelfed fediverse logos)
|
||||
Copyright (c) Simple Icons contributors (public domain dedication)
|
||||
License: CC0 1.0 Universal (no rights reserved, no attribution required)
|
||||
Home: https://simpleicons.org/
|
||||
Source: https://github.com/simple-icons/simple-icons
|
||||
Used as: path data compiled into the app's `SocialServiceIcons` so each
|
||||
social entry renders the service's real logo. CC0 imposes no
|
||||
obligations; it is listed here per this file's "list everything"
|
||||
policy.
|
||||
|
||||
9. NAudio (WASAPI audio capture — loopback + mic)
|
||||
Copyright (c) Mark Heath and contributors
|
||||
License: MIT
|
||||
Home: https://github.com/naudio/NAudio
|
||||
Used as: the WASAPI loopback (desktop/game audio) and mic capture sources
|
||||
behind the `IAudioSource` seam (TASK 4 ship step 4). MIT imposes
|
||||
no source offer; this notice is kept per this file's policy.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
MISCELLANEOUS
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
|
||||
<Style x:Key="IconButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="#d0d0d0"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="4"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
|
||||
+592
-163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
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>
|
||||
/// 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
|
||||
/// validation, the freemium lock, and the YouTube sign-out. Deliberately WPF-free
|
||||
/// (no MessageBox, no Window) so the whole flow is unit-testable with fakes.
|
||||
/// The window surfaces the confirmations (delete, sign-in gate) as dialogs and
|
||||
/// calls back into the VM.
|
||||
/// </summary>
|
||||
public sealed class SocialsDialogViewModel : ViewModelBase
|
||||
{
|
||||
public const int MaxSlots = 6;
|
||||
|
||||
private readonly ISocialValidator _validator;
|
||||
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||
private readonly Func<Task> _signOutAction;
|
||||
private readonly bool _isPremium;
|
||||
private readonly List<DialogEntry> _working = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private YouTubeChannel? _account;
|
||||
|
||||
private bool _isSignedIn;
|
||||
private bool _isBusy;
|
||||
private bool _barEnabled = true;
|
||||
|
||||
public SocialsDialogViewModel(
|
||||
ISocialValidator validator,
|
||||
Func<Task<YouTubeChannel?>> signInProvider,
|
||||
Func<Task> signOutAction,
|
||||
YouTubeChannel? account,
|
||||
bool isPremium,
|
||||
SocialsConfig? current = null)
|
||||
{
|
||||
_validator = validator;
|
||||
_signInProvider = signInProvider;
|
||||
_signOutAction = signOutAction;
|
||||
_isPremium = isPremium;
|
||||
_barEnabled = current?.BarEnabled ?? true;
|
||||
_account = account;
|
||||
_isSignedIn = account != null;
|
||||
|
||||
foreach (var entry in current?.Entries ?? [])
|
||||
if (entry.Service != SocialService.YouTube)
|
||||
_working.Add(new DialogEntry(entry.Service, entry.Handle, entry.ProfileUrl));
|
||||
|
||||
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => CanSignIn);
|
||||
SaveCommand = new RelayCommand(_ => Save(), _ => CanSave);
|
||||
CancelCommand = new RelayCommand(_ => Cancel());
|
||||
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
public ObservableCollection<SocialSlotViewModel> Slots { get; } = new();
|
||||
|
||||
public bool BarEnabled
|
||||
{
|
||||
get => _barEnabled;
|
||||
set => SetProperty(ref _barEnabled, value);
|
||||
}
|
||||
|
||||
public bool IsSignedIn
|
||||
{
|
||||
get => _isSignedIn;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isSignedIn, value))
|
||||
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowSignInBanner => !IsSignedIn && !IsBusy;
|
||||
public bool CanSignIn => !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Save is blocked while any slot has un-validated input, is mid-validation,
|
||||
/// or failed validation — an empty slot or a confirmed entry never blocks.
|
||||
/// </summary>
|
||||
public bool CanSave =>
|
||||
!IsBusy &&
|
||||
Slots.All(s => !s.IsEditing || string.IsNullOrWhiteSpace(s.EditText)) &&
|
||||
Slots.All(s => !s.IsValidating && !s.HasError);
|
||||
|
||||
public ICommand SignInCommand { get; }
|
||||
public ICommand SaveCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public event Action? SaveRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
/// <summary>Entries to persist after a successful save (YouTube first when signed in).</summary>
|
||||
public IReadOnlyList<SocialEntry>? CommittedEntries { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dismissal is a hard stop: abort every in-flight validation and get out.
|
||||
/// The canceled continuations never touch slot state.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_cts.Cancel();
|
||||
CancelRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Aborts in-flight work without raising <see cref="CancelRequested"/>
|
||||
/// (used by the window's Closing handler so a Save close isn't clobbered).</summary>
|
||||
public void AbortPending() => _cts.Cancel();
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
if (IsBusy) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var channel = await _signInProvider();
|
||||
if (channel != null)
|
||||
{
|
||||
_account = channel;
|
||||
IsSignedIn = true;
|
||||
RebuildSlots();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts editing the slot's input box (add or edit).</summary>
|
||||
public void StartEdit(int slotIndex)
|
||||
{
|
||||
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||
var slot = Slots[slotIndex];
|
||||
if (slot.IsLocked || slot.IsYoutube || slot.IsValidating) return;
|
||||
slot.EditText = slot.IsFilled ? slot.Handle : string.Empty;
|
||||
slot.Error = null;
|
||||
slot.IsEditing = true;
|
||||
}
|
||||
|
||||
/// <summary>Commits the slot's input — empty cancels, otherwise validates.
|
||||
/// Re-submitting text that was already handled is a no-op, so a focus shift
|
||||
/// (clicking Cancel, tabbing away) never re-fires a lookup.</summary>
|
||||
public void ConfirmEdit(int slotIndex)
|
||||
{
|
||||
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||
var slot = Slots[slotIndex];
|
||||
if (!slot.IsEditing || slot.IsValidating) return;
|
||||
if (string.IsNullOrWhiteSpace(slot.EditText))
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
var text = slot.EditText.Trim();
|
||||
if (slot.IsFilled && text == slot.Handle)
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
if (slot.LastValidatedInput == text)
|
||||
{
|
||||
if (slot.IsFilled) slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
slot.LastValidatedInput = text;
|
||||
_ = ValidateAsync(slot);
|
||||
}
|
||||
|
||||
/// <summary>Runs the lookup; on success snaps the row back to its logo + handle.
|
||||
/// A canceled lookup (dismissal) leaves the slot untouched.</summary>
|
||||
private async Task ValidateAsync(SocialSlotViewModel slot)
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
slot.IsValidating = true;
|
||||
slot.Error = null;
|
||||
try
|
||||
{
|
||||
var text = slot.EditText.Trim();
|
||||
var (service, handle, _) = SocialServiceIcons.DetectService(text);
|
||||
var result = await _validator.LookupAsync(service, handle, ct);
|
||||
if (ct.IsCancellationRequested || result.Canceled) return;
|
||||
|
||||
if (result.Success)
|
||||
ApplyValidated(slot, new DialogEntry(service, result.Handle, result.ProfileUrl, result.FediverseSoftware));
|
||||
else
|
||||
slot.Error = result.Error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
slot.IsValidating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyValidated(SocialSlotViewModel slot, DialogEntry entry)
|
||||
{
|
||||
var index = slot.Index - 1;
|
||||
if (slot.IsFilled && index >= 0 && index < _working.Count)
|
||||
_working[index] = entry;
|
||||
else
|
||||
_working.Add(entry);
|
||||
slot.Service = entry.Service;
|
||||
slot.Handle = entry.Handle;
|
||||
slot.ProfileUrl = entry.ProfileUrl;
|
||||
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||
slot.IsFilled = true;
|
||||
slot.IsEditing = false;
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
/// <summary>The window confirms the delete, then calls this. Slot 0 signs out of YouTube.</summary>
|
||||
public async Task DeleteSlotAsync(int slotIndex)
|
||||
{
|
||||
if (slotIndex == 0)
|
||||
{
|
||||
if (!IsSignedIn) return;
|
||||
await _signOutAction();
|
||||
_account = null;
|
||||
IsSignedIn = false;
|
||||
RebuildSlots();
|
||||
return;
|
||||
}
|
||||
|
||||
var entryIndex = slotIndex - 1;
|
||||
if (entryIndex < 0 || entryIndex >= _working.Count) return;
|
||||
_working.RemoveAt(entryIndex);
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
if (!CanSave) return;
|
||||
var entries = new List<SocialEntry>();
|
||||
if (IsSignedIn && _account != null)
|
||||
{
|
||||
var handle = _account.DisplayName.Trim().TrimStart('@');
|
||||
entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = handle,
|
||||
ProfileUrl = string.IsNullOrWhiteSpace(_account.ChannelId)
|
||||
? SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle)
|
||||
: $"https://www.youtube.com/channel/{_account.ChannelId}",
|
||||
});
|
||||
}
|
||||
foreach (var entry in _working)
|
||||
entries.Add(new SocialEntry
|
||||
{
|
||||
Service = entry.Service,
|
||||
Handle = entry.Handle,
|
||||
ProfileUrl = entry.ProfileUrl,
|
||||
FediverseSoftware = entry.FediverseSoftware,
|
||||
});
|
||||
CommittedEntries = entries;
|
||||
SaveRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Builds the config the main window commits on save, or null when empty.</summary>
|
||||
public SocialsConfig? BuildSocialsConfig()
|
||||
{
|
||||
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
|
||||
if (entries.Count == 0) return null;
|
||||
var config = new SocialsConfig { BarEnabled = BarEnabled };
|
||||
foreach (var entry in entries)
|
||||
config.Entries.Add(entry);
|
||||
return config;
|
||||
}
|
||||
|
||||
private void RebuildSlots()
|
||||
{
|
||||
foreach (var slot in Slots)
|
||||
slot.PropertyChanged -= OnSlotPropertyChanged;
|
||||
Slots.Clear();
|
||||
|
||||
for (var i = 0; i < MaxSlots; i++)
|
||||
{
|
||||
var slot = new SocialSlotViewModel(i);
|
||||
if (i == 0)
|
||||
{
|
||||
slot.Service = SocialService.YouTube;
|
||||
slot.IsSignIn = !IsSignedIn;
|
||||
slot.IsFilled = IsSignedIn;
|
||||
if (IsSignedIn && _account != null)
|
||||
slot.Handle = _account.DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
var entryIndex = i - 1;
|
||||
if (entryIndex < _working.Count)
|
||||
{
|
||||
var entry = _working[entryIndex];
|
||||
slot.Service = entry.Service;
|
||||
slot.IsFilled = true;
|
||||
slot.Handle = entry.Handle;
|
||||
slot.ProfileUrl = entry.ProfileUrl;
|
||||
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||
}
|
||||
else if (!_isPremium && i >= 2)
|
||||
{
|
||||
slot.IsLocked = true;
|
||||
}
|
||||
}
|
||||
slot.PropertyChanged += OnSlotPropertyChanged;
|
||||
Slots.Add(slot);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSlotPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(SocialSlotViewModel.IsEditing)
|
||||
or nameof(SocialSlotViewModel.IsValidating)
|
||||
or nameof(SocialSlotViewModel.HasError)
|
||||
or nameof(SocialSlotViewModel.EditText))
|
||||
{
|
||||
OnPropertyChanged(nameof(CanSave));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -89,9 +89,9 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel |
|
||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)** |
|
||||
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
|
||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
|
||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")** |
|
||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
|
||||
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||
@@ -100,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
|
||||
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevel * MicVolume)` (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
||||
@@ -112,9 +112,9 @@ C# / WPF (.NET 8) following MVVM:
|
||||
### Current limitations / TODOs
|
||||
|
||||
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||
- Scene/source/asset layout persists (SQLite, schema v6); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED** — full plan in `TASKS.md`; the encoder subprocess + RTMP push, audio capture, and the frame-pipeline wiring follow (each its own PR)
|
||||
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
|
||||
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
||||
|
||||
### Screen backdrop capture (TASK 3 ship task #1)
|
||||
@@ -180,6 +180,14 @@ instead of a normal draggable source.
|
||||
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
|
||||
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
|
||||
game are behind the picker; picker-based captures don't survive reload.
|
||||
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
|
||||
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
|
||||
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
|
||||
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
|
||||
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
|
||||
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
|
||||
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
|
||||
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
|
||||
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
|
||||
to the encoder task.
|
||||
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
|
||||
@@ -330,6 +338,165 @@ const). Constructor-injected search dirs / tools dir / downloader (`Func<string,
|
||||
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
|
||||
encoder step (not yet — this PR ships the seam + impl + tests only).
|
||||
|
||||
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
|
||||
|
||||
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
|
||||
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
|
||||
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop),
|
||||
`SubmitFrameAsync` (serialized stdin writes under `SemaphoreSlim`), `StopAsync` (stdin EOF → ffmpeg
|
||||
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
|
||||
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
|
||||
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
|
||||
+ exit control); a `Func<IEncoderProcess>` factory + the locator are constructor-injected, so the
|
||||
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
|
||||
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
|
||||
|
||||
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
|
||||
`-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + a **silent placeholder
|
||||
`-f lavfi -i anullsrc`** track (WASAPI capture, ship step 4, replaces it) + `-c:v <enc> -b:v K
|
||||
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
|
||||
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>`.
|
||||
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
|
||||
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
|
||||
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
|
||||
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
|
||||
driven by the `FramePump` below.
|
||||
|
||||
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12, game audio bar follow-up 2026-08-13, plans in TASKS.md)
|
||||
|
||||
**Capture runs for the app's lifetime and is KISS by rule**: desktop/game audio is automatic (WASAPI
|
||||
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
|
||||
layer sits behind an **`IAudioSource` seam** (`Services/Audio/`: `Start`/`Stop`/`Started`/`SampleReady`/
|
||||
`Failed`, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
|
||||
devices, no timers).
|
||||
|
||||
- **Sources (NAudio `NAudio.Wasapi` 2.2.1, MIT — item 9 in `THIRD-PARTY-NOTICES.txt`):**
|
||||
`WasapiLoopbackAudioSource` = `WasapiLoopbackCapture` on the default render device;
|
||||
`WasapiMicAudioSource` = `WasapiCapture` with the device resolved by `FriendlyName` matching
|
||||
`MicSourceName` (the app only persists the **DisplayName**), falling back to the default capture
|
||||
endpoint. Mic device resolution re-reads the name provider `Func<string?>` at each `Start`, so a mic
|
||||
picked mid-session takes effect **immediately** (the mixer restarts the mic on pick). Both sources
|
||||
raise `Started` once their capture loop actually begins — the mixer turns that into `MicConnected`.
|
||||
- **`AudioMixer`** owns both sources; **`StartMicCaptureAsync` starts the mixer once at startup** and
|
||||
`Shutdown` disposes it — NOT go-live — so the meters preview live (`BeginGoLive`/`StopStream`
|
||||
no longer touch the mixer). Mic samples feed a pure **`AudioLevelMeter`** (RMS with 0.2 exponential
|
||||
smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`; loopback samples feed a
|
||||
second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level is
|
||||
mapped to the meter's display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1): real speech/
|
||||
game RMS is ~0.01..0.1 linear, which would leave a flat scale looking dead. **Mic connection state
|
||||
surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and
|
||||
`RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via
|
||||
`AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` is
|
||||
unconditional (a `?.` on the event would skip the argument — and the meter update — when nothing is
|
||||
subscribed yet).
|
||||
- **Mic status dot (`Models/MicStatus.cs`)** on the footer's MIC button: green = `MicConnected`,
|
||||
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
|
||||
so loopback and the game bar can't run either — no capture devices at all).
|
||||
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview):
|
||||
`IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
|
||||
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active
|
||||
bar**), and the default `GameAudioDetector` composing `IFullScreenDetector` + the live loopback level
|
||||
(floor 0.5%). WPF-free — the VM owns a 250ms `DispatcherTimer` that polls it and flips
|
||||
`IsGameAudioBarVisible`. The bar is **overlaid at the bottom of the preview window** (bottom-center,
|
||||
dark translucent chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI
|
||||
only — it lives in the XAML preview (`MainWindow.xaml`, the PreviewGrid) and never reaches the live
|
||||
output (the `SceneCompositor` doesn't know about it).
|
||||
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct,
|
||||
PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
|
||||
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
|
||||
- Build **0 warnings**; **169 passing** (mixer/hysteresis/game-detector/meter-scale unit tests).
|
||||
|
||||
**Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame
|
||||
pipeline shipped in ship step 5).
|
||||
|
||||
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
|
||||
|
||||
The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active
|
||||
scene each tick, resolves every element to its latest frame, composites it into the tier's output frame,
|
||||
and paces frames into the encoder at the tier's FPS. **Pattern — everything is a constructor-injected
|
||||
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, and an injectable pacing delay
|
||||
(default `Task.Delay`; tests inject `Task.Yield`). The pump is free of WPF and of the capture managers.
|
||||
|
||||
- **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log
|
||||
+ surface via the `Failed` event. `EncoderOptions == null` means "no RTMP URL": the pump logs and skips
|
||||
the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func<string?>` returning null
|
||||
until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow.
|
||||
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
|
||||
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
|
||||
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
|
||||
`StreamStatus.Error` (minimal).
|
||||
- **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar —
|
||||
`MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on
|
||||
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
|
||||
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
|
||||
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
|
||||
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
|
||||
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` →
|
||||
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` →
|
||||
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/
|
||||
background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*`
|
||||
doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`,
|
||||
ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one.
|
||||
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
|
||||
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
|
||||
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
|
||||
(WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread,
|
||||
the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render`
|
||||
blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` =
|
||||
bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame`
|
||||
on load/save/`NotifySocialsChanged`).
|
||||
- Known consideration: the pump reads the active scene on a background thread while the UI can still edit
|
||||
it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than
|
||||
crashing. The background thread + video pipeline is the new reality since this step.
|
||||
|
||||
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
|
||||
|
||||
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
|
||||
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
|
||||
**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
|
||||
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
|
||||
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
|
||||
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped,
|
||||
column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` +
|
||||
`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes):
|
||||
ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle;
|
||||
signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on
|
||||
freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` →
|
||||
**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam +
|
||||
`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure =
|
||||
rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` →
|
||||
`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column,
|
||||
column-presence migration, no version bump) so the entry shows the **instance's real logo**
|
||||
(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse
|
||||
honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates
|
||||
(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates
|
||||
`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup
|
||||
**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a
|
||||
`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
|
||||
never touch slot state).
|
||||
|
||||
**Bar bug-fix branch (2026-08-13) — three changes:**
|
||||
|
||||
1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that
|
||||
permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the
|
||||
bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on
|
||||
release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't
|
||||
let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom
|
||||
(`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone.
|
||||
2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because
|
||||
nodeinfo was only ever asked of the identity domain (a landing page; the real instance is
|
||||
`mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes
|
||||
well-known subdomains** (`mastodon.` → `social.` → … `FediverseSubdomainCandidates`) when the identity
|
||||
domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the
|
||||
static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher,
|
||||
and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place.
|
||||
3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a
|
||||
transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in,
|
||||
Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
|
||||
pipeline").
|
||||
|
||||
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
|
||||
|
||||
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
|
||||
@@ -352,6 +519,10 @@ guardrails below — written down so a future "quick fix" never reintroduces a G
|
||||
— the obligations are not optional.
|
||||
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
|
||||
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
|
||||
- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons**
|
||||
(CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or
|
||||
another public-domain source; a logo asset under a copyleft or attribution license would contaminate
|
||||
the paid product.
|
||||
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
|
||||
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
|
||||
release blocker). Queued early is wrong; the release pass owns it.
|
||||
|
||||
@@ -52,6 +52,8 @@ that every other memory file follows. Read `ai.md` first; this file explains
|
||||
|
||||
## Integration rule
|
||||
|
||||
Every feature change ships with its memory update: `ai.md` for architecture /
|
||||
patterns, `TASKS.md` for status, index files when the layout changes. That is
|
||||
what keeps the map accurate enough to trust next session.
|
||||
Every feature change ships with its memory update **in the same commit**:
|
||||
`ai.md` for architecture / patterns, `TASKS.md` for status, index files when
|
||||
the layout changes. No code commit without its docs — a follow-up "docs
|
||||
backfill" commit is a broken rule, not a style. That is what keeps the map
|
||||
accurate enough to trust next session.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
using NAudio.Wave;
|
||||
using Xunit;
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 4 ship step 4: WASAPI audio capture behind the <see cref="IAudioSource"/>
|
||||
/// seam. The units pin down the pure pieces — byte→float conversion, the level
|
||||
/// meter math, and the mixer lifecycle/forwarding against fakes. No real audio
|
||||
/// devices (NAudio device resolution is a thin wrapper left to a manual smoke
|
||||
/// test), no timers.
|
||||
/// </summary>
|
||||
public class AudioMixerTests
|
||||
{
|
||||
private sealed class FakeSource : IAudioSource
|
||||
{
|
||||
public int StartCount { get; private set; }
|
||||
public int StopCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start() => StartCount++;
|
||||
public void Stop() => StopCount++;
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public void MarkStarted() => Started?.Invoke();
|
||||
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
|
||||
public void Fail(Exception ex) => Failed?.Invoke(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_StartsBothSources()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
|
||||
mixer.Start();
|
||||
|
||||
Assert.Equal(1, mic.StartCount);
|
||||
Assert.Equal(1, loopback.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_IsIdempotent()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
|
||||
mixer.Start();
|
||||
mixer.Start();
|
||||
|
||||
Assert.Equal(1, mic.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_StopsBothAndResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 0.8f }, 48000, 1));
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
mixer.Stop();
|
||||
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
Assert.Equal(1, loopback.StopCount);
|
||||
Assert.Equal(0f, last);
|
||||
Assert.Equal(0f, mixer.MicLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WithNoStart_DoesNothing()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
|
||||
mixer.Stop();
|
||||
|
||||
Assert.Equal(0, mic.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicSamples_DriveMicLevelChanged()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
mic.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 1));
|
||||
|
||||
Assert.NotEmpty(levels);
|
||||
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackSamples_DoNotChangeMicLevel()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||
|
||||
Assert.Empty(levels);
|
||||
Assert.Equal(0f, mixer.MicLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicSamples_DoNotChangeLoopbackLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var levels = new List<float>();
|
||||
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
|
||||
Assert.Empty(levels);
|
||||
Assert.Equal(0f, mixer.LoopbackLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackSamples_DriveLoopbackLevelChanged()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||
var levels = new List<float>();
|
||||
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
|
||||
|
||||
Assert.NotEmpty(levels);
|
||||
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicStarted_RaisesMicConnected()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var connected = 0;
|
||||
mixer.MicConnected += () => connected++;
|
||||
|
||||
mixer.Start();
|
||||
mic.MarkStarted();
|
||||
|
||||
Assert.Equal(1, connected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicFailure_RaisesMicFailed()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
Exception? failed = null;
|
||||
mixer.MicFailed += ex => failed = ex;
|
||||
|
||||
mixer.Start();
|
||||
mic.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("boom", failed!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
|
||||
mixer.RestartMic();
|
||||
|
||||
Assert.Equal(2, mic.StartCount);
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
Assert.Equal(1, loopback.StartCount);
|
||||
Assert.Equal(0, loopback.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartMic_ResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
Assert.True(mixer.MicLevel > 0);
|
||||
|
||||
float? reset = null;
|
||||
mixer.MicLevelChanged += l => reset = l;
|
||||
mixer.RestartMic();
|
||||
|
||||
Assert.Equal(0f, reset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicFailure_LogsAndResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var logs = new List<string>();
|
||||
var mixer = new AudioMixer(mic, new FakeSource(), m => logs.Add(m));
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 0.5f }, 48000, 1));
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
mic.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Contains(logs, l => l.Contains("boom"));
|
||||
Assert.Equal(0f, last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackFailure_LogsButKeepsMic()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var logs = new List<string>();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback, m => logs.Add(m));
|
||||
mixer.Start();
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
loopback.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Contains(logs, l => l.Contains("boom"));
|
||||
Assert.Equal(-1f, last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_StopsAndDisposesSources()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
|
||||
mixer.Dispose();
|
||||
|
||||
Assert.True(mic.Disposed);
|
||||
Assert.True(loopback.Disposed);
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesEvents()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Dispose();
|
||||
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
mic.Emit(new AudioSample(new[] { 1f }, 48000, 1));
|
||||
|
||||
Assert.Empty(levels);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioLevelMeterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstantSine_ConvergesToRms()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
var sample = new AudioSample(new[] { 0.5f, -0.5f, 0.5f, -0.5f }, 48000, 1);
|
||||
|
||||
float level = 0;
|
||||
for (var i = 0; i < 30; i++)
|
||||
level = meter.Push(sample);
|
||||
|
||||
Assert.InRange(level, 0.45f, 0.55f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Silence_DrivesTowardZero()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
var samples = new float[1024];
|
||||
var sample = new AudioSample(samples, 48000, 1);
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
meter.Push(sample);
|
||||
|
||||
Assert.Equal(0f, meter.Level, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySample_KeepsLevel()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
var before = meter.Level;
|
||||
|
||||
var level = meter.Push(new AudioSample(Array.Empty<float>(), 48000, 1));
|
||||
|
||||
Assert.Equal(before, level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ZerosLevel()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
|
||||
meter.Reset();
|
||||
|
||||
Assert.Equal(0f, meter.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDisplay_IsLogarithmic()
|
||||
{
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
|
||||
|
||||
// -60 dBFS floor → 0, 0 dBFS → 1, and a decade (~-20 dB) is 1/3 up the bar.
|
||||
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
|
||||
Assert.Equal(0.5f, AudioLevelMeter.ToDisplay(0.0316f), 2);
|
||||
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f);
|
||||
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.32f, 0.34f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDisplay_ClampsAtFloor()
|
||||
{
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.0001f));
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(-1f));
|
||||
}
|
||||
}
|
||||
|
||||
public class WaveToFloatTests
|
||||
{
|
||||
private static byte[] FloatSamples(params float[] values)
|
||||
{
|
||||
var bytes = new byte[values.Length * 4];
|
||||
Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IeeeFloat32_PreservesValues()
|
||||
{
|
||||
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(FloatSamples(0.25f, -0.5f, 1f), 12, format);
|
||||
|
||||
Assert.Equal(3, samples.Length);
|
||||
Assert.Equal(0.25f, samples[0], 4);
|
||||
Assert.Equal(-0.5f, samples[1], 4);
|
||||
Assert.Equal(1f, samples[2], 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pcm16_NormalizesToUnitRange()
|
||||
{
|
||||
var format = WaveFormat.CreateCustomFormat(WaveFormatEncoding.Pcm, 48000, 1, 48000 * 2, 2, 16);
|
||||
var bytes = new byte[] { 0x00, 0x00, 0xFF, 0x7F, 0x00, 0x80 };
|
||||
var samples = WaveToFloat.Convert(bytes, 6, format);
|
||||
|
||||
Assert.Equal(3, samples.Length);
|
||||
Assert.Equal(0f, samples[0], 4);
|
||||
Assert.Equal(1f, samples[1], 4);
|
||||
Assert.Equal(-1f, samples[2], 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncatedTrailingBytes_AreIgnored()
|
||||
{
|
||||
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var bytes = new byte[] { 0, 0, 0x80, 0x3F, 1, 2, 3 }; // 1 float + 3 stray bytes
|
||||
|
||||
var samples = WaveToFloat.Convert(bytes, bytes.Length, format);
|
||||
|
||||
Assert.Single(samples);
|
||||
Assert.Equal(1f, samples[0], 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The FFmpeg subprocess encoder (TASK 4 ship step 3). The integration test drives
|
||||
/// the full lifecycle against fakes: encoder probe → subprocess start with the right
|
||||
/// args → raw frames into stdin → stderr progress parsed into health → graceful stop.
|
||||
/// The units pin down the pure pieces (args, progress parser, encoder picker) and
|
||||
/// the failure edges. No real ffmpeg binary, no network.
|
||||
/// </summary>
|
||||
public class FfmpegEncoderTests
|
||||
{
|
||||
private sealed class QueuedReader : TextReader
|
||||
{
|
||||
private readonly Channel<string?> _channel = Channel.CreateUnbounded<string?>();
|
||||
public void Enqueue(string s) => _channel.Writer.TryWrite(s);
|
||||
public void Complete() => _channel.Writer.TryComplete();
|
||||
public override string? ReadLine() => ReadLineAsync().GetAwaiter().GetResult();
|
||||
|
||||
public override async Task<string?> ReadLineAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _channel.Reader.ReadAsync().AsTask().ConfigureAwait(false);
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
return null; // stream EOF — the process exited and the pipe closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEncoderProcess : IEncoderProcess
|
||||
{
|
||||
public ProcessStartInfo? StartInfo { get; private set; }
|
||||
public MemoryStream Stdin { get; } = new();
|
||||
public Stream StandardInput => Stdin;
|
||||
public TextReader StandardOutput { get; }
|
||||
public bool HasExited { get; private set; }
|
||||
public int ExitCode { get; private set; }
|
||||
public bool Killed { get; private set; }
|
||||
public bool Started { get; private set; }
|
||||
|
||||
private readonly TaskCompletionSource _exit =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly QueuedReader _error = new();
|
||||
|
||||
public FakeEncoderProcess(string probeOutput = "")
|
||||
=> StandardOutput = new StringReader(probeOutput);
|
||||
|
||||
public TextReader StandardError => _error;
|
||||
public void EnqueueStderr(string line) => _error.Enqueue(line);
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
StartInfo = startInfo;
|
||||
Started = true;
|
||||
}
|
||||
|
||||
public void SignalExit(int code = 0)
|
||||
{
|
||||
ExitCode = code;
|
||||
HasExited = true;
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
Killed = true;
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exit.Task;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEncoderProcessFactory
|
||||
{
|
||||
private readonly Queue<FakeEncoderProcess> _processes = new();
|
||||
public void Return(FakeEncoderProcess p) => _processes.Enqueue(p);
|
||||
public FakeEncoderProcess Create() => _processes.Dequeue();
|
||||
}
|
||||
|
||||
private sealed class StubLocator : IFfmpegLocator
|
||||
{
|
||||
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(@"C:\tools\ffmpeg.exe");
|
||||
}
|
||||
|
||||
private static byte[] BgraFrame(int w, int h, byte b, byte g, byte r)
|
||||
{
|
||||
var bytes = new byte[w * h * 4];
|
||||
for (var i = 0; i < bytes.Length; i += 4)
|
||||
{
|
||||
bytes[i] = b;
|
||||
bytes[i + 1] = g;
|
||||
bytes[i + 2] = r;
|
||||
bytes[i + 3] = 255;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_ProbesEncoder_FeedsFrames_ParsesHealth_StopsGracefully()
|
||||
{
|
||||
const string encoders =
|
||||
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
|
||||
" V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||
var probe = new FakeEncoderProcess(encoders);
|
||||
var encoderProc = new FakeEncoderProcess();
|
||||
var factory = new FakeEncoderProcessFactory();
|
||||
factory.Return(probe);
|
||||
factory.Return(encoderProc);
|
||||
|
||||
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||
|
||||
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
encoder.HealthUpdated += (_, h) => health.TrySetResult(h);
|
||||
|
||||
var options = new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc-xyz",
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
Fps = 60,
|
||||
BitrateKbps = 8000,
|
||||
};
|
||||
|
||||
await encoder.StartAsync(options);
|
||||
|
||||
Assert.True(probe.Started, "the -encoders probe must run");
|
||||
Assert.True(encoderProc.Started, "the encoder subprocess must start");
|
||||
Assert.Equal(@"C:\tools\ffmpeg.exe", encoderProc.StartInfo!.FileName);
|
||||
var args = encoderProc.StartInfo.ArgumentList.ToArray();
|
||||
var c = Array.IndexOf(args, "-c:v");
|
||||
Assert.True(
|
||||
args[c + 1] == "h264_nvenc",
|
||||
"hardware NVENC must be preferred over the listed openh264");
|
||||
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
|
||||
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
|
||||
|
||||
encoderProc.EnqueueStderr(
|
||||
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
|
||||
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(4000.1, h.CurrentBitrate, 1);
|
||||
Assert.Equal(59.9, h.FPS, 1);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), h.StreamDuration);
|
||||
|
||||
encoderProc.SignalExit();
|
||||
await encoder.StopAsync();
|
||||
Assert.False(encoder.IsRunning);
|
||||
Assert.False(encoderProc.Killed, "graceful stop must not kill the process");
|
||||
Assert.Equal(StreamStatus.Offline, h.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WithoutRtmpUrl_Throws()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitFrame_WhenNotRunning_Throws()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => encoder.SubmitFrameAsync(new VideoFrame(2, 2, new byte[16])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_WithoutStart_IsNoop()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await encoder.StopAsync();
|
||||
Assert.False(encoder.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessDeath_RaisesFailed()
|
||||
{
|
||||
var encoderProc = new FakeEncoderProcess();
|
||||
var factory = new FakeEncoderProcessFactory();
|
||||
factory.Return(new FakeEncoderProcess());
|
||||
factory.Return(encoderProc);
|
||||
|
||||
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
encoder.ProcessFailed += (_, msg) => failed.TrySetResult(msg);
|
||||
|
||||
await encoder.StartAsync(new EncoderOptions { RtmpUrl = "rtmp://x/y" });
|
||||
encoderProc.SignalExit(1);
|
||||
var msg = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("1", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_Gop_IsFpsTimesFour()
|
||||
{
|
||||
var options = new EncoderOptions { Fps = 60 };
|
||||
Assert.Equal(240, options.GopSize);
|
||||
|
||||
var args = FfmpegArgs.Build(options, "libopenh264").ToArray();
|
||||
var g = Array.IndexOf(args, "-g");
|
||||
Assert.Equal("240", args[g + 1]);
|
||||
var keyint = Array.IndexOf(args, "-keyint_min");
|
||||
Assert.Equal("240", args[keyint + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_IncludeInputOutputAndCompliance()
|
||||
{
|
||||
var args = FfmpegArgs.Build(
|
||||
new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
Fps = 60,
|
||||
BitrateKbps = 8000,
|
||||
},
|
||||
"libopenh264").ToArray();
|
||||
|
||||
Assert.Contains("-f", args);
|
||||
Assert.Contains("rawvideo", args);
|
||||
Assert.Contains("pipe:0", args);
|
||||
Assert.Contains("1920x1080", args);
|
||||
Assert.Contains("anullsrc=channel_layout=stereo:sample_rate=48000", args);
|
||||
Assert.Contains("-sc_threshold", args);
|
||||
Assert.Contains("-bf", args);
|
||||
Assert.Contains("yuv420p", args);
|
||||
Assert.Contains("aac", args);
|
||||
Assert.Contains("flv", args);
|
||||
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressParser_ParsesRealStatsLine()
|
||||
{
|
||||
var p = FfmpegProgressParser.TryParse(
|
||||
"frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x");
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(123, p.Value.Frame);
|
||||
Assert.Equal(59.9, p.Value.Fps, 1);
|
||||
Assert.Equal(4000.1, p.Value.BitrateKbps, 1);
|
||||
Assert.Equal(2.04, p.Value.Duration.TotalSeconds, 2);
|
||||
Assert.Equal(1024 * 1024, p.Value.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressParser_IgnoresBannerAndErrors()
|
||||
{
|
||||
Assert.Null(FfmpegProgressParser.TryParse("ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers"));
|
||||
Assert.Null(FfmpegProgressParser.TryParse("Error while opening encoder for output stream #0:0"));
|
||||
Assert.Null(FfmpegProgressParser.TryParse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncoderPicker_PrefersHardware_NeverLibx264()
|
||||
{
|
||||
var listing =
|
||||
" V..... libx264 libx264 H.264 / AVC (codec h264)\n" +
|
||||
" V..... libopenh264 OpenH264 H.264 (codec h264)\n" +
|
||||
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n";
|
||||
Assert.Equal("h264_nvenc", FfmpegEncoderPicker.Pick(listing));
|
||||
|
||||
var onlyGpl = " V..... libx264 libx264 H.264 / AVC (codec h264)\n";
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(onlyGpl));
|
||||
|
||||
var software = " V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(software));
|
||||
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
|
||||
/// the tier's FPS and paces frames into the encoder. The integration test drives
|
||||
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
|
||||
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
|
||||
/// that stop tears the pump down cleanly. The units pin the failure edges: the
|
||||
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
|
||||
/// </summary>
|
||||
public class FramePumpTests
|
||||
{
|
||||
private sealed class FakeEncoder : IFfmpegEncoder
|
||||
{
|
||||
public readonly List<VideoFrame> Frames = new();
|
||||
public int StartCount;
|
||||
public int StopCount;
|
||||
public bool Disposed;
|
||||
public EncoderOptions? LastOptions;
|
||||
public Exception? StartError;
|
||||
public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
public event EventHandler<string>? ProcessFailed;
|
||||
|
||||
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
StartCount++;
|
||||
LastOptions = options;
|
||||
if (StartError != null) throw StartError;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Frames.Add(frame);
|
||||
FrameArrived.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
StopCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
|
||||
|
||||
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
}
|
||||
|
||||
private static Scene BackdropScene()
|
||||
{
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" });
|
||||
return scene;
|
||||
}
|
||||
|
||||
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
|
||||
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
|
||||
List<string>? log = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
|
||||
{
|
||||
return new FramePump(
|
||||
sceneProvider: scene ?? (() => BackdropScene()),
|
||||
frameResolver: resolve ?? (_ => null),
|
||||
compositorOptions: () => new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
|
||||
OutputWidth = 64, OutputHeight = 48,
|
||||
},
|
||||
encoderOptions: options ?? (() => new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
|
||||
Width = 64, Height = 48, Fps = 60,
|
||||
}),
|
||||
encoderFactory: () => encoder,
|
||||
log: log != null ? m => log.Add(m) : null,
|
||||
pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
|
||||
socialBar: socialBar);
|
||||
}
|
||||
|
||||
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
|
||||
{
|
||||
var i = (y * frame.Width + x) * 4;
|
||||
var br = frame.BgraPixels[i + 2];
|
||||
var bg = frame.BgraPixels[i + 1];
|
||||
var bb = frame.BgraPixels[i];
|
||||
Assert.True(
|
||||
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
|
||||
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
|
||||
{
|
||||
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder,
|
||||
resolve: e => e is Source { IsBackdrop: true } ? red : null);
|
||||
|
||||
await pump.StartAsync();
|
||||
Assert.True(pump.IsRunning);
|
||||
Assert.Equal(1, encoder.StartCount);
|
||||
|
||||
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.NotEmpty(encoder.Frames);
|
||||
var frame = encoder.Frames[0];
|
||||
Assert.Equal(64, frame.Width);
|
||||
Assert.Equal(48, frame.Height);
|
||||
AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in
|
||||
|
||||
await pump.StopAsync();
|
||||
Assert.False(pump.IsRunning);
|
||||
Assert.Equal(1, encoder.StopCount);
|
||||
Assert.True(encoder.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var log = new List<string>();
|
||||
using var pump = NewPump(encoder, options: () => null, log: log);
|
||||
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
Assert.Equal(0, encoder.StartCount);
|
||||
Assert.Contains(log, m => m.Contains("RTMP"));
|
||||
|
||||
await pump.StopAsync(); // no-op after a skipped start
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WhileRunning_IsNoop()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder);
|
||||
|
||||
await pump.StartAsync();
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.Equal(1, encoder.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_WithoutStart_IsNoop()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder);
|
||||
|
||||
await pump.StopAsync();
|
||||
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
Assert.False(pump.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
|
||||
{
|
||||
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
|
||||
var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
|
||||
Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip
|
||||
|
||||
var encoder = new FakeEncoder();
|
||||
var position = SocialBarPosition.Top;
|
||||
using var pump = NewPump(encoder,
|
||||
resolve: e => e is Source { IsBackdrop: true } ? red : null,
|
||||
socialBar: () => (bar, position));
|
||||
|
||||
await pump.StartAsync();
|
||||
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.NotEmpty(encoder.Frames);
|
||||
AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge
|
||||
|
||||
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
|
||||
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
|
||||
// top corner clears back to backdrop.
|
||||
position = SocialBarPosition.Bottom;
|
||||
VideoFrame? flipped = null;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && flipped == null)
|
||||
{
|
||||
for (var i = 1; i < encoder.Frames.Count; i++)
|
||||
{
|
||||
var f = encoder.Frames[i];
|
||||
if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
|
||||
{
|
||||
flipped = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (flipped == null) await Task.Delay(10);
|
||||
}
|
||||
Assert.NotNull(flipped);
|
||||
AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge
|
||||
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
|
||||
{
|
||||
var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.Failed += (_, m) => failed.TrySetResult(m);
|
||||
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("access denied", message);
|
||||
Assert.True(encoder.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessDeath_StopsPump_AndRaisesFailed()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.Failed += (_, m) => failed.TrySetResult(m);
|
||||
|
||||
await pump.StartAsync();
|
||||
encoder.RaiseProcessFailed("FFmpeg exited with code 1");
|
||||
|
||||
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("1", message);
|
||||
|
||||
// The pump self-stops (fire-and-forget); poll the definitive teardown
|
||||
// marker — the encoder's disposal — rather than the IsRunning flag, which
|
||||
// StopAsync clears before the loop has fully drained.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!encoder.Disposed && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
Assert.True(encoder.Disposed);
|
||||
Assert.False(pump.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthUpdated_ForwardsEncoderHealth()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.HealthUpdated += (_, h) => health.TrySetResult(h);
|
||||
|
||||
await pump.StartAsync();
|
||||
encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });
|
||||
|
||||
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(StreamStatus.Streaming, h.Status);
|
||||
Assert.Equal(59.9, h.FPS, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 4 game audio bar: the default detector's provider wiring — it composes
|
||||
/// the full-screen monitor + loopback level into the hysteresis and raises
|
||||
/// IsGameAudioActiveChanged on transitions. The transition math itself lives in
|
||||
/// GameAudioHysteresisTests.
|
||||
/// </summary>
|
||||
public class GameAudioDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Poll_RaisesChanged_WhenGameAppearsAndLeaves()
|
||||
{
|
||||
var now = new DateTime(2026, 8, 13, 12, 0, 0);
|
||||
int? monitor = 0;
|
||||
var level = 0f;
|
||||
var detector = new GameAudioDetector(() => monitor, () => level, () => now);
|
||||
var changes = new List<bool>();
|
||||
detector.IsGameAudioActiveChanged += a => changes.Add(a);
|
||||
|
||||
level = 0.9f;
|
||||
detector.Poll();
|
||||
Assert.False(detector.IsGameAudioActive);
|
||||
|
||||
now = now.AddMilliseconds(600);
|
||||
detector.Poll();
|
||||
Assert.True(detector.IsGameAudioActive);
|
||||
Assert.Equal(new[] { true }, changes);
|
||||
|
||||
level = 0f;
|
||||
now = now.AddSeconds(2);
|
||||
detector.Poll();
|
||||
Assert.True(detector.IsGameAudioActive);
|
||||
Assert.Equal(new[] { true }, changes);
|
||||
|
||||
monitor = null;
|
||||
detector.Poll();
|
||||
Assert.True(detector.IsGameAudioActive);
|
||||
|
||||
now = now.AddSeconds(2);
|
||||
detector.Poll();
|
||||
Assert.False(detector.IsGameAudioActive);
|
||||
Assert.Equal(new[] { true, false }, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_StaysInactive_WhenNoFullScreenMonitor()
|
||||
{
|
||||
var detector = new GameAudioDetector(() => null, () => 0.9f);
|
||||
var changes = 0;
|
||||
detector.IsGameAudioActiveChanged += _ => changes++;
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
detector.Poll();
|
||||
|
||||
Assert.False(detector.IsGameAudioActive);
|
||||
Assert.Equal(0, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_StaysInactive_WhileLoopbackSilent()
|
||||
{
|
||||
var detector = new GameAudioDetector(() => 0, () => 0f);
|
||||
var changes = 0;
|
||||
detector.IsGameAudioActiveChanged += _ => changes++;
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
detector.Poll();
|
||||
|
||||
Assert.False(detector.IsGameAudioActive);
|
||||
Assert.Equal(0, changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 4 game audio bar: the pure show/hide state machine. Show = a full-screen
|
||||
/// app holds sound for half a second; hide = the app leaves fullscreen for a
|
||||
/// second. Silence never hides an active bar — only the game leaving the preview
|
||||
/// does (per the creator's rule).
|
||||
/// </summary>
|
||||
public class GameAudioHysteresisTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 8, 13, 12, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void StaysInactive_WhileSilent()
|
||||
{
|
||||
var h = new GameAudioHysteresis();
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
h.Update(true, false, T0.AddSeconds(i));
|
||||
Assert.False(h.IsActive);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaysInactive_UntilSoundHoldsHalfSecond()
|
||||
{
|
||||
var h = new GameAudioHysteresis();
|
||||
h.Update(true, true, T0);
|
||||
Assert.False(h.IsActive);
|
||||
h.Update(true, true, T0.AddMilliseconds(400));
|
||||
Assert.False(h.IsActive);
|
||||
|
||||
h.Update(true, true, T0.AddMilliseconds(600));
|
||||
Assert.True(h.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverHides_WhileGameStillFullScreen_EvenWhenSilent()
|
||||
{
|
||||
var h = new GameAudioHysteresis();
|
||||
h.Update(true, true, T0);
|
||||
h.Update(true, true, T0.AddSeconds(1));
|
||||
Assert.True(h.IsActive);
|
||||
|
||||
for (var i = 2; i < 40; i++)
|
||||
{
|
||||
h.Update(true, false, T0.AddSeconds(i));
|
||||
Assert.True(h.IsActive);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hides_AfterAppLeavesFullScreen()
|
||||
{
|
||||
var h = new GameAudioHysteresis();
|
||||
h.Update(true, true, T0);
|
||||
h.Update(true, true, T0.AddSeconds(1));
|
||||
Assert.True(h.IsActive);
|
||||
|
||||
h.Update(false, true, T0.AddSeconds(2));
|
||||
Assert.True(h.IsActive);
|
||||
|
||||
h.Update(false, true, T0.AddSeconds(4));
|
||||
Assert.False(h.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaysInactive_WhenNoFullScreen_EvenWithSound()
|
||||
{
|
||||
var h = new GameAudioHysteresis();
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
h.Update(false, true, T0.AddSeconds(i));
|
||||
Assert.False(h.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that spin up the real WPF App need exactly one Application instance per
|
||||
/// AppDomain (WPF enforces it) — so they share this serial collection and a
|
||||
/// single App created on one dedicated STA thread. Marshal test bodies onto it
|
||||
/// via <see cref="RealAppHost.Run"/>, never `new App()` per test.
|
||||
/// </summary>
|
||||
[CollectionDefinition("RealApp", DisableParallelization = true)]
|
||||
public sealed class RealAppCollection : ICollectionFixture<RealAppHost>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Owns the one-and-only WPF App on a dedicated STA thread.</summary>
|
||||
public sealed class RealAppHost : IDisposable
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public RealAppHost()
|
||||
{
|
||||
Exception? boot = null;
|
||||
Dispatcher? dispatcher = null;
|
||||
_thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
dispatcher = Dispatcher.CurrentDispatcher;
|
||||
Dispatcher.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
boot = ex;
|
||||
}
|
||||
});
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
while (dispatcher == null && boot == null)
|
||||
Thread.Sleep(5);
|
||||
if (boot != null)
|
||||
throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
|
||||
_dispatcher = dispatcher!;
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="action"/> on the App's STA thread and rethrows any failure.</summary>
|
||||
public void Run(Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
_dispatcher.Invoke(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
if (failure != null)
|
||||
throw failure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_dispatcher.InvokeShutdown();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The App never ran its message loop — shutdown is best-effort.
|
||||
}
|
||||
_thread.Join(2000);
|
||||
}
|
||||
}
|
||||
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
|
||||
/// (doesn't fall through and deselect the source) and whether the round clip
|
||||
/// renders as a circle rather than an oval.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class RoundClipInteractionTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public RoundClipInteractionTests(RealAppHost app)
|
||||
{
|
||||
_app = app;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
|
||||
{
|
||||
Exception? failure = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (failure != null)
|
||||
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
|
||||
// Never let the real MainWindow read/write the user's actual layout DB —
|
||||
// Shutdown() saves the layout, which would persist these test sources
|
||||
// over the real ones. Point it at a throwaway temp DB instead.
|
||||
|
||||
@@ -180,6 +180,43 @@ public class SceneCompositorTests
|
||||
// untouched corner stays pure red
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0);
|
||||
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(backdrop);
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||||
OutputWidth = 1920, OutputHeight = 1080,
|
||||
};
|
||||
var compositor = new SceneCompositor();
|
||||
|
||||
// master-sized overlay: opaque green at (0,0), 50%-white at the center
|
||||
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||||
var g = 0;
|
||||
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
|
||||
var w = (540 * 1920 + 960) * 4;
|
||||
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
|
||||
|
||||
// flash: opaque magenta at (0,0) — the bar must cover it
|
||||
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||||
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
|
||||
|
||||
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
|
||||
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
|
||||
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
|
||||
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
|
||||
|
||||
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
|
||||
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
|
||||
AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
|
||||
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
|
||||
}
|
||||
}
|
||||
|
||||
public class StretchMathTests
|
||||
|
||||
@@ -145,6 +145,28 @@ public class ScreenCaptureManagerTests
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
|
||||
{
|
||||
FakeScreenSource? captured = null;
|
||||
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key));
|
||||
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0"));
|
||||
|
||||
Assert.True(await manager.AcquireAsync("monitor:0"));
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0")); // nothing pumped yet
|
||||
|
||||
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
|
||||
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
|
||||
captured!.Pump(first);
|
||||
captured.Pump(second);
|
||||
|
||||
Assert.Same(second, manager.GetLatestFrame("monitor:0"));
|
||||
|
||||
await manager.ReleaseAsync("monitor:0");
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0"));
|
||||
}
|
||||
|
||||
// The one integration test for this branch: one target creates one shared
|
||||
// WriteableBitmap, published once, and back-to-back frames coalesce to the
|
||||
// latest (a single pending UI copy per session).
|
||||
|
||||
+153
-13
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
@@ -18,6 +19,83 @@ public class SocialBarTests
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Resolves every fediverse domain as Mastodon — the heal test only
|
||||
/// cares that a missing stored software name gets filled in and persisted.</summary>
|
||||
private sealed class MastodonValidator : ISocialValidator
|
||||
{
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>("mastodon");
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
=> throw new System.NotSupportedException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialEntry_FediverseSoftware_SettableUpdatesLogo()
|
||||
{
|
||||
var entry = new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
};
|
||||
var honeycomb = entry.LogoData;
|
||||
entry.FediverseSoftware = "mastodon";
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), entry.LogoData);
|
||||
Assert.NotEqual(honeycomb, entry.LogoData);
|
||||
}
|
||||
|
||||
/// <summary>The single integration test for this branch: a fediverse entry
|
||||
/// persisted with a NULL software name is loaded, healed via the real validator
|
||||
/// seam, and the resolved name is persisted back — surviving a second load.</summary>
|
||||
[Fact]
|
||||
public async Task Socials_HealMissingFediverseSoftware_RoundTripsThroughDb()
|
||||
{
|
||||
var path = TempDbPath();
|
||||
try
|
||||
{
|
||||
var socials = new SocialsConfig();
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
});
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { new Scene { Name = "Live" } }, null, socials);
|
||||
|
||||
SocialsConfig? loaded;
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.Load();
|
||||
loaded = store.Socials;
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Null(loaded!.Entries[0].FediverseSoftware);
|
||||
}
|
||||
|
||||
var healed = await MainViewModel.HealFediverseSoftwareAsync(loaded!, new MastodonValidator());
|
||||
Assert.Single(healed);
|
||||
Assert.Equal("mastodon", healed["@gramps@llamachile.tube"]);
|
||||
loaded.Entries[0].FediverseSoftware = healed["@gramps@llamachile.tube"];
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { new Scene { Name = "Live" } }, null, loaded);
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.Load();
|
||||
var again = store.Socials;
|
||||
Assert.NotNull(again);
|
||||
Assert.Equal("mastodon", again!.Entries[0].FediverseSoftware);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
|
||||
{
|
||||
@@ -27,7 +105,7 @@ public class SocialBarTests
|
||||
var socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = SocialBarPosition.Top,
|
||||
BarJustify = SocialBarJustify.Left,
|
||||
BarEnabled = false,
|
||||
};
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
@@ -41,6 +119,13 @@ public class SocialBarTests
|
||||
Handle = "streamer123",
|
||||
ProfileUrl = "https://www.twitch.tv/streamer123",
|
||||
});
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
});
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
@@ -53,13 +138,16 @@ public class SocialBarTests
|
||||
{
|
||||
var scenes = store.Load();
|
||||
Assert.NotNull(store.Socials);
|
||||
Assert.Equal(2, store.Socials!.Entries.Count);
|
||||
Assert.Equal(3, store.Socials!.Entries.Count);
|
||||
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||
Assert.Equal("mychannel", store.Socials.Entries[0].Handle);
|
||||
Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl);
|
||||
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||
Assert.Equal(SocialService.Fediverse, store.Socials.Entries[2].Service);
|
||||
Assert.Equal("@gramps@llamachile.tube", store.Socials.Entries[2].Handle);
|
||||
Assert.Equal("peertube", store.Socials.Entries[2].FediverseSoftware);
|
||||
Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition);
|
||||
Assert.Equal(SocialBarJustify.Left, store.Socials.BarJustify);
|
||||
Assert.False(store.Socials.BarEnabled);
|
||||
Assert.True(scenes[0].HasSocialBar);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +161,7 @@ public class SocialBarTests
|
||||
[Fact]
|
||||
public void SocialsConfig_EmptyEntries_NotPersisted()
|
||||
{
|
||||
var path = TempDBPath();
|
||||
var path = TempDbPath();
|
||||
try
|
||||
{
|
||||
var socials = new SocialsConfig();
|
||||
@@ -94,6 +182,12 @@ public class SocialBarTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialsConfig_DefaultBarEnabled_IsOn()
|
||||
{
|
||||
Assert.True(new SocialsConfig().BarEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_CanonicalUrlFor_AllServices()
|
||||
{
|
||||
@@ -103,17 +197,63 @@ public class SocialBarTests
|
||||
Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Link, "@user@site.tld"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Fediverse, "@user@site.tld"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_InitialsAndColor_AllServices()
|
||||
public void SocialServiceIcons_LogoData_AllServices()
|
||||
{
|
||||
Assert.Equal("YT", SocialServiceIcons.InitialsFor(SocialService.YouTube));
|
||||
Assert.Equal("#FF0000", SocialServiceIcons.ColorFor(SocialService.YouTube));
|
||||
Assert.Equal("TW", SocialServiceIcons.InitialsFor(SocialService.Twitch));
|
||||
Assert.NotEmpty(SocialServiceIcons.InitialsFor(SocialService.Website));
|
||||
Assert.NotEmpty(SocialServiceIcons.ColorFor(SocialService.Website));
|
||||
foreach (var service in Enum.GetValues<SocialService>())
|
||||
Assert.False(string.IsNullOrWhiteSpace(SocialServiceIcons.LogoDataFor(service)), service.ToString());
|
||||
Assert.NotEmpty(SocialServiceIcons.LockedIconData);
|
||||
Assert.NotEmpty(SocialServiceIcons.DoNotIconData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.YouTube), SocialServiceIcons.LogoDataFor(SocialService.Twitch));
|
||||
}
|
||||
|
||||
private static string TempDBPath() => TempDbPath();
|
||||
[Fact]
|
||||
public void SocialServiceIcons_LogoDataForFediverse_KnownAndUnknownSoftware()
|
||||
{
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("PeerTube"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), SocialServiceIcons.LogoDataForFediverse("MASTODON"));
|
||||
Assert.NotEqual(
|
||||
SocialServiceIcons.LogoDataForFediverse("peertube"),
|
||||
SocialServiceIcons.LogoDataForFediverse("mastodon"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse(null), SocialServiceIcons.LogoDataForFediverse("gotosocial"));
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialEntry_FediverseLogo_ComesFromSoftwareName()
|
||||
{
|
||||
var entry = new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
};
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.Link), entry.LogoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_ByDomain()
|
||||
{
|
||||
Assert.Equal(SocialService.YouTube, SocialServiceIcons.DetectService("youtube.com/@handle").service);
|
||||
Assert.Equal(SocialService.X, SocialServiceIcons.DetectService("https://x.com/user").service);
|
||||
Assert.Equal(SocialService.Instagram, SocialServiceIcons.DetectService("instagram.com/user").service);
|
||||
var fediverse = SocialServiceIcons.DetectService("@user@instance.tube");
|
||||
Assert.Equal(SocialService.Fediverse, fediverse.service);
|
||||
Assert.Equal("@user@instance.tube", fediverse.handle);
|
||||
Assert.Equal("https://instance.tube/@user", fediverse.url);
|
||||
Assert.Equal(SocialService.GitHub, SocialServiceIcons.DetectService("https://github.com/dev").service);
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("example.com/path").service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_BareHandle_FallsBackToWebsite()
|
||||
{
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("justaname").service);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialValidatorTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
|
||||
public StubHandler(HttpResponseMessage response) : this(_ => response) { }
|
||||
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return Task.FromCanceled<HttpResponseMessage>(ct);
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpSocialValidator Validator(HttpMessageHandler handler)
|
||||
=> new(new HttpClient(handler));
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_BuildsHostUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://site.tld/@user", result.ProfileUrl);
|
||||
Assert.Equal("@user@site.tld", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_FetchesInstanceSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(RespondFediverse("peertube")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@gramps@llamachile.tube", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("peertube", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!;
|
||||
if (uri.Host == "site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath == "/")
|
||||
{
|
||||
// Identity domain's default app redirects to the real instance.
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://social.site.tld/"),
|
||||
};
|
||||
}
|
||||
if (uri.AbsolutePath.StartsWith("/@"))
|
||||
return new HttpResponseMessage(HttpStatusCode.OK); // profile validates
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound); // nodeinfo SSO-blocked
|
||||
}
|
||||
if (uri.Host == "social.site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath.StartsWith("/.well-known"))
|
||||
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://social.site.tld/nodeinfo/2.0" } } });
|
||||
if (uri.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("mastodon", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_NodeInfoUnavailable_StillSucceeds()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
request.RequestUri!.AbsolutePath.StartsWith("/.well-known")
|
||||
? new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains()
|
||||
{
|
||||
// The identity domain is a silent landing page (no nodeinfo, no redirect);
|
||||
// the real instance lives on mastodon.<domain> — the probe must find it.
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
{
|
||||
var host = request.RequestUri!.Host;
|
||||
if (host == "mastodon.llamachile.tube")
|
||||
{
|
||||
if (request.RequestUri.AbsolutePath.StartsWith("/.well-known"))
|
||||
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } });
|
||||
if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}));
|
||||
|
||||
var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None);
|
||||
|
||||
Assert.Equal("mastodon", software);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
|
||||
var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None);
|
||||
|
||||
Assert.Null(software);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var request = 0;
|
||||
var validator = Validator(new StubHandler(_ =>
|
||||
{
|
||||
if (++request >= 2) cts.Cancel();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestMessage, HttpResponseMessage> RespondFediverse(string software)
|
||||
{
|
||||
return request =>
|
||||
{
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/.well-known"))
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
links = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
rel = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
href = $"https://{request.RequestUri.Host}/nodeinfo/2.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
{
|
||||
return Json(new { software = new { name = software, version = "1.0.0" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(object payload)
|
||||
=> new(HttpStatusCode.OK) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload)) };
|
||||
|
||||
[Fact]
|
||||
public async Task KnownServiceHandle_CanonicalUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://x.com/creator", result.ProfileUrl);
|
||||
Assert.Equal("creator", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotFound_IsRejected()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "doesnotexist", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("NotFound", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redirect_CountsAsExists()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.Found)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.GitHub, "dev", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionFailure_IsFriendlyError()
|
||||
{
|
||||
var validator = Validator(new StubHandler(_ => throw new HttpRequestException("boom")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "user", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Couldn't reach", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledToken_AbortsRequest()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialsDialogViewModelTests
|
||||
{
|
||||
private sealed class FakeValidator : ISocialValidator
|
||||
{
|
||||
public int LookupCount { get; private set; }
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>("mastodon");
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
LookupCount++;
|
||||
if (handleOrUrl.StartsWith('@') && handleOrUrl.IndexOf('@', 1) > 0)
|
||||
{
|
||||
var at = handleOrUrl.IndexOf('@', 1);
|
||||
var user = handleOrUrl[1..at];
|
||||
var domain = handleOrUrl[(at + 1)..];
|
||||
return Task.FromResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = handleOrUrl,
|
||||
ProfileUrl = $"https://{domain}/@{user}",
|
||||
FediverseSoftware = "peertube",
|
||||
});
|
||||
}
|
||||
var handle = handleOrUrl.Trim().TrimStart('@');
|
||||
if (handle == "doesnotexist")
|
||||
return Task.FromResult(new SocialLookupResult { Error = "404 for 'doesnotexist'." });
|
||||
return Task.FromResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = handle,
|
||||
ProfileUrl = SocialServiceIcons.CanonicalUrlFor(service, handle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds the lookup open so a test can cancel mid-flight and then
|
||||
/// resolve it — proving a dismissed dialog never applies the result.</summary>
|
||||
private sealed class BlockingValidator : ISocialValidator
|
||||
{
|
||||
public TaskCompletionSource<SocialLookupResult> Gate { get; } = new();
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
=> Gate.Task;
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class SignInFake
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public Task<YouTubeChannel?> Next()
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult<YouTubeChannel?>(
|
||||
new YouTubeChannel { ChannelId = "UCabc", DisplayName = "My Channel" });
|
||||
}
|
||||
}
|
||||
|
||||
private int _signOutCalls;
|
||||
private Task SignOut() { _signOutCalls++; return Task.CompletedTask; }
|
||||
|
||||
private SocialsDialogViewModel Create(
|
||||
ISocialValidator? validator = null,
|
||||
SignInFake? signIn = null,
|
||||
YouTubeChannel? account = null,
|
||||
bool isPremium = false,
|
||||
SocialsConfig? current = null)
|
||||
{
|
||||
return new SocialsDialogViewModel(
|
||||
validator ?? new FakeValidator(),
|
||||
signIn != null ? signIn.Next : () => Task.FromResult<YouTubeChannel?>(null),
|
||||
SignOut,
|
||||
account,
|
||||
isPremium,
|
||||
current);
|
||||
}
|
||||
|
||||
// ── ONE integration test: the full dialog-VM flow with fakes ──
|
||||
|
||||
[Fact]
|
||||
public async Task FullFlow_Freemium_SignInAddDeleteSignOutPersistence()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var signIn = new SignInFake();
|
||||
var vm = Create(validator, signIn);
|
||||
|
||||
// Gate: signed out, freemium → row 0 is the sign-in prompt, rows 2-5 locked.
|
||||
Assert.True(vm.ShowSignInBanner);
|
||||
Assert.Equal(6, vm.Slots.Count);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
Assert.False(vm.Slots[0].IsLocked);
|
||||
Assert.False(vm.Slots[1].IsLocked);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
Assert.True(vm.Slots[5].IsLocked);
|
||||
Assert.True(vm.CanSave);
|
||||
|
||||
// Row 1: add a validated X handle. Save is blocked while input is unvalidated.
|
||||
vm.StartEdit(1);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
Assert.False(vm.CanSave);
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.Equal(SocialService.X, vm.Slots[1].Service);
|
||||
Assert.Equal("creator", vm.Slots[1].Handle);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.CanSave);
|
||||
|
||||
// Rows 2-5 are locked on the free tier — starting an edit there is blocked.
|
||||
vm.StartEdit(2);
|
||||
Assert.False(vm.Slots[2].IsEditing);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
// Delete the X entry: the slot empties and unlocks nothing else.
|
||||
await vm.DeleteSlotAsync(1);
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsLocked);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
// Delete row 0 while signed out is a no-op.
|
||||
await vm.DeleteSlotAsync(0);
|
||||
Assert.Equal(0, _signOutCalls);
|
||||
|
||||
// Sign in: row 0 fills with the channel; the X slot is still free.
|
||||
vm.SignInCommand.Execute(null);
|
||||
Assert.Equal(1, signIn.Calls);
|
||||
Assert.True(vm.IsSignedIn);
|
||||
Assert.False(vm.Slots[0].IsSignIn);
|
||||
Assert.Equal("My Channel", vm.Slots[0].Handle);
|
||||
Assert.False(vm.ShowSignInBanner);
|
||||
|
||||
// Fill row 1 again and save: YouTube-first ordering, bar enabled.
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "twitch.tv/streamer";
|
||||
vm.ConfirmEdit(1);
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
Assert.Equal(2, vm.CommittedEntries!.Count);
|
||||
Assert.Equal(SocialService.YouTube, vm.CommittedEntries[0].Service);
|
||||
Assert.Equal("My Channel", vm.CommittedEntries[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
|
||||
Assert.Equal("streamer", vm.CommittedEntries[1].Handle);
|
||||
|
||||
// Sign out via the row-0 delete: action called once, row 0 back to the gate.
|
||||
await vm.DeleteSlotAsync(0);
|
||||
Assert.Equal(1, _signOutCalls);
|
||||
Assert.False(vm.IsSignedIn);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
|
||||
// Full persistence roundtrip through the store.
|
||||
var config = vm.BuildSocialsConfig();
|
||||
Assert.NotNull(config);
|
||||
Assert.True(config!.BarEnabled);
|
||||
Assert.Equal(2, config.Entries.Count);
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-flow-{System.Guid.NewGuid():N}.db");
|
||||
SqliteConnection.ClearAllPools();
|
||||
try
|
||||
{
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { new Scene { Name = "Live" } }, null, config);
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.Load();
|
||||
Assert.NotNull(store.Socials);
|
||||
Assert.Equal(2, store.Socials!.Entries.Count);
|
||||
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||
Assert.True(store.Socials.BarEnabled);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests ──
|
||||
|
||||
[Fact]
|
||||
public void Freemium_LocksSlotsBeyondTwo_PremiumUnlocksAll()
|
||||
{
|
||||
var free = Create();
|
||||
Assert.True(free.Slots[2].IsLocked);
|
||||
Assert.True(free.Slots[5].IsLocked);
|
||||
Assert.False(free.Slots[1].IsLocked);
|
||||
|
||||
var premium = Create(isPremium: true);
|
||||
Assert.All(premium.Slots, s => Assert.False(s.IsLocked));
|
||||
Assert.False(premium.Slots[5].IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationFailure_LeavesSlotEditableAndBlocksSave()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/doesnotexist";
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.CanSave);
|
||||
|
||||
vm.Slots[1].EditText = "github.com/dev";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(SocialService.GitHub, vm.Slots[1].Service);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.True(vm.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyEdit_CancelsEditing()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/user";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedsFromCurrent_YouTubeBecomesAccountRow()
|
||||
{
|
||||
var current = new SocialsConfig { BarEnabled = false };
|
||||
current.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = "stale-yt-handle",
|
||||
ProfileUrl = "https://www.youtube.com/@stale-yt-handle",
|
||||
});
|
||||
current.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Twitch,
|
||||
Handle = "oldstreamer",
|
||||
ProfileUrl = "https://www.twitch.tv/oldstreamer",
|
||||
});
|
||||
|
||||
var vm = Create(account: new YouTubeChannel { ChannelId = "UC123", DisplayName = "Fresh Channel" }, current: current);
|
||||
|
||||
Assert.False(vm.BarEnabled);
|
||||
Assert.Equal("Fresh Channel", vm.Slots[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.Slots[1].Service);
|
||||
Assert.Equal("oldstreamer", vm.Slots[1].Handle);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.Equal(2, vm.CommittedEntries!.Count);
|
||||
Assert.Equal("Fresh Channel", vm.CommittedEntries[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
|
||||
Assert.False(vm.BuildSocialsConfig()!.BarEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySave_BuildsNullConfig()
|
||||
{
|
||||
var vm = Create();
|
||||
Assert.True(vm.CanSave);
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
Assert.Empty(vm.CommittedEntries!);
|
||||
Assert.Null(vm.BuildSocialsConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignOut_ResetsRowZeroToGate()
|
||||
{
|
||||
var signIn = new SignInFake();
|
||||
var vm = Create(signIn: signIn, account: new YouTubeChannel { ChannelId = "UC1", DisplayName = "Ch" });
|
||||
Assert.True(vm.IsSignedIn);
|
||||
|
||||
await vm.DeleteSlotAsync(0);
|
||||
|
||||
Assert.Equal(1, _signOutCalls);
|
||||
Assert.False(vm.IsSignedIn);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
Assert.True(vm.ShowSignInBanner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FediverseHandle_ValidatesAndKeepsFullHandle()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "@creator@instance.tube";
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.Equal(SocialService.Fediverse, vm.Slots[1].Service);
|
||||
Assert.Equal("@creator@instance.tube", vm.Slots[1].Handle);
|
||||
Assert.Equal("https://instance.tube/@creator", vm.Slots[1].ProfileUrl);
|
||||
Assert.Equal("peertube", vm.Slots[1].FediverseSoftware);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), vm.Slots[1].LogoData);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FediverseEntry_CommitsSoftwareName()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "@creator@instance.tube";
|
||||
vm.ConfirmEdit(1);
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
var entry = Assert.Single(vm.CommittedEntries!);
|
||||
Assert.Equal(SocialService.Fediverse, entry.Service);
|
||||
Assert.Equal("peertube", entry.FediverseSoftware);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
|
||||
var config = vm.BuildSocialsConfig();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), Assert.Single(config.Entries).LogoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_AbortsInFlightValidation_WithoutMutatingSlot()
|
||||
{
|
||||
var validator = new BlockingValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.True(vm.Slots[1].IsValidating);
|
||||
|
||||
vm.Cancel();
|
||||
// The lookup resolves with success AFTER dismissal — it must not apply.
|
||||
validator.Gate.TrySetResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = "creator",
|
||||
ProfileUrl = "https://x.com/creator",
|
||||
});
|
||||
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.False(vm.Slots[1].IsValidating);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReSubmittingIdenticalFailedInput_DoesNotRelookup()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/doesnotexist";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
|
||||
// LostFocus firing on Cancel re-submits the same failed text — no second lookup.
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
|
||||
// Editing the text re-enables validation.
|
||||
vm.Slots[1].EditText = "github.com/dev";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(2, validator.LookupCount);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReEditingUnchangedHandle_ClosesBox_NoRelookup()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
|
||||
vm.StartEdit(1);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Duplicate source names get an incrementing suffix with no space (Image,
|
||||
/// Image2, Image3…) and the next free number is derived from the names actually
|
||||
/// in the scene — deleting a middle resource never collides with a survivor.
|
||||
/// Drives the real VM through the Add Source command (TextOverlay needs no
|
||||
/// dialog), the same real-App + temp-DB pattern as the round-clip test.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class SourceNamingTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public SourceNamingTests(RealAppHost app)
|
||||
{
|
||||
_app = app;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_Sources_Get_Next_Free_Numbered_Name()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-srcname-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
var scene = vm.ActiveScene!;
|
||||
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
|
||||
|
||||
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
|
||||
scene.Elements.Remove(middle);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
||||
<PackageReference Include="NAudio.Wasapi" Version="2.2.1"/>
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user