Compare commits
10 Commits
e494dce311
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d90b5ded0d | |||
| 8292663791 | |||
| dc29adf9bb | |||
| 18abe4210f | |||
| 9f9ed34627 | |||
| ea250c02a6 | |||
| ac60a26d82 | |||
| e72ba71165 | |||
| 58c0f8e8c4 | |||
| ba427e85e1 |
+71
-61
@@ -5,71 +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)
|
||||
|
||||
- **Branch:** `main` — the social bar v2 push is **committed + pushed** (`9873eed`,
|
||||
18 files, +1878/−321, 112 tests passing, 0 warnings). Working tree is **dirty
|
||||
with the post-push docs backfill only** (ai.md, README.md, TASKS.md schema
|
||||
section, THIRD-PARTY-NOTICES.txt) — ready to commit when the user says so.
|
||||
- **Finished this session (round 3):** user's live-test report said the mastodon
|
||||
icon still didn't show for `@gramps@llamachile.tube`. Root cause: the identity
|
||||
domain `llamachile.tube` is YunoHost-SSO-gated — `/.well-known/nodeinfo`,
|
||||
`/@gramps`, and webfinger all answer with the SSO login page, so nodeinfo
|
||||
returned nothing and the icon fell back to the honeycomb glyph. The real
|
||||
instance lives at `mastodon.llamachile.tube`, and only the bare root
|
||||
`https://llamachile.tube/` 302s to it ("default app" redirect). Fix:
|
||||
`HttpSocialValidator.TryFetchFediverseSoftwareAsync` now, when nodeinfo on the
|
||||
identity domain fails, follows the root redirect (`ResolveInstanceHostAsync`,
|
||||
reads `resp.RequestMessage.RequestUri.Host`) and re-runs the nodeinfo lookup
|
||||
on the resolved host. New test `FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware`.
|
||||
Build: **0 warnings**. Tests: **112 passing**.
|
||||
- **What landed (all rounds this session):**
|
||||
1. **Fediverse icon resolution** — `DetectService` maps `@user@domain` to the
|
||||
new `SocialService.Fediverse` (was `Link`/chain icon). On validate,
|
||||
`HttpSocialValidator` best-effort GETs `https://{domain}/.well-known/nodeinfo`,
|
||||
follows the first nodeinfo `links[].href`, reads `software.name`, and returns
|
||||
it in `SocialLookupResult.FediverseSoftware`. `SocialEntry`/`SocialSlotViewModel`
|
||||
carry `FediverseSoftware`; `SocialServiceIcons.LogoDataForFediverse(software)`
|
||||
maps it to a bundled logo (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/
|
||||
firefish — Simple Icons CC0), falling back to the `FediverseIconData`
|
||||
honeycomb glyph for unknown software (GoToSocial/Sharkey/Akkoma aren't in
|
||||
Simple Icons). Nodeinfo failure still validates — the glyph falls back.
|
||||
2. **Redirect resolution (round 3)** — identity domains that 302 their root
|
||||
to the real instance (YunoHost default-app subdomains) resolve software via
|
||||
the root redirect when identity-domain nodeinfo is SSO-blocked.
|
||||
3. **Persistence** — new `SocialEntry.Software TEXT` column via
|
||||
`MigrateSocialEntryTable()` (column-presence pattern, same as the others);
|
||||
saved/loaded alongside Service/Handle/ProfileUrl.
|
||||
4. **Icon colors** — `IconButton` style gains `Foreground="#d0d0d0"`
|
||||
(`Themes/Controls.xaml`); the dialog's trash button overrides
|
||||
`Foreground="#e94560"` (`SocialsDialog.xaml`). All other `IconButton`
|
||||
usages are `Path` content with explicit `Fill`, so unaffected.
|
||||
- **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:**
|
||||
- `LayoutStore.Socials` is only populated by `Load()` — tests must call
|
||||
`store.Load()` before asserting it.
|
||||
- `DetectService("justaname")` → **Website** with empty handle; only
|
||||
unparseable input or `@user@domain` yields Link/Fediverse.
|
||||
- Dialog sign-in provider is `Func<Task<YouTubeChannel?>>`; test fakes must
|
||||
return `Task.FromResult` (sync-completed) so the fire-and-forget command
|
||||
settles before the next assert.
|
||||
- `SocialBarBottomTop = 1040` literal lives in `MainWindow.xaml.cs` (the VM's
|
||||
`MasterFrameHeight` is private).
|
||||
- `Cancel_AbortsInFlightValidation_WithoutMutatingSlot` relies on the
|
||||
BlockingValidator's gate completing synchronously (no
|
||||
`RunContinuationsAsynchronously`) — the assertions run after
|
||||
`Gate.TrySetResult` returns because the awaited continuation executes inline.
|
||||
- Nodeinfo cancellation test cancels mid-lookup via a counting stub; the
|
||||
post-fetch `ct.IsCancellationRequested` check is what reports `Canceled`.
|
||||
- Round-3 redirect fallback fires only when identity-domain nodeinfo fails;
|
||||
the root follow happens automatically (HttpClient default auto-redirect),
|
||||
and `RequestMessage.RequestUri.Host` is read from the final response.
|
||||
- **Next step:** TASK 4 ship step 3 — the encoder + RTMP push (FFmpeg subprocess:
|
||||
frames via stdin, stderr health parsing, FLV mux + push to the cached reusable
|
||||
stream's ingestion URL). Nothing else queued — do not expand the task queue on
|
||||
your own.
|
||||
- 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; the new `SocialEntry.Software`
|
||||
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`.
|
||||
|
||||
+166
-94
@@ -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}}">
|
||||
@@ -654,13 +652,12 @@
|
||||
|
||||
<!-- 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. Dragging snaps to the nearest edge. -->
|
||||
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}}"
|
||||
PreviewMouseLeftButtonDown="SocialBar_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseMove="SocialBar_PreviewMouseMove"
|
||||
PreviewMouseLeftButtonUp="SocialBar_PreviewMouseLeftButtonUp">
|
||||
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
|
||||
Cursor="Hand">
|
||||
<Grid.Effect>
|
||||
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
|
||||
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
|
||||
@@ -680,9 +677,7 @@
|
||||
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>
|
||||
@@ -745,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>
|
||||
@@ -800,9 +852,10 @@
|
||||
|
||||
<!-- 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 ("it just is" —
|
||||
WASAPI loopback at unity, zero UI); the creator's only audio
|
||||
control is the mic — meter, volume, mute. -->
|
||||
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"/>
|
||||
@@ -825,13 +878,19 @@
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Meter + volume: centered under the preview panel -->
|
||||
<!-- Mic meter + volume: centered under the preview panel -->
|
||||
<StackPanel Grid.Column="1" 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"/>
|
||||
<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). -->
|
||||
@@ -863,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">
|
||||
|
||||
+39
-65
@@ -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,41 +257,12 @@ public partial class MainWindow : Window
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
// ─── Social bar drag: vertical only, snaps to top/bottom on release ───
|
||||
private const double SocialBarBottomTop = 1040;
|
||||
private bool _isDraggingSocialBar;
|
||||
private double _socialBarGrabOffsetY;
|
||||
|
||||
private void SocialBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
// ─── 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)
|
||||
{
|
||||
var bar = (FrameworkElement)sender;
|
||||
_isDraggingSocialBar = true;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(bar));
|
||||
_socialBarGrabOffsetY = p.Y - Canvas.GetTop(bar);
|
||||
bar.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SocialBar_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_isDraggingSocialBar) return;
|
||||
var bar = (FrameworkElement)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(bar));
|
||||
Canvas.SetTop(bar, Math.Clamp(p.Y - _socialBarGrabOffsetY, 0, SocialBarBottomTop));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SocialBar_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_isDraggingSocialBar) return;
|
||||
var bar = (FrameworkElement)sender;
|
||||
var top = Canvas.GetTop(bar);
|
||||
bar.ReleaseMouseCapture();
|
||||
_isDraggingSocialBar = false;
|
||||
var position = top <= SocialBarBottomTop / 2.0 ? SocialBarPosition.Top : SocialBarPosition.Bottom;
|
||||
_viewModel.SetSocialBarPosition(position);
|
||||
_viewModel.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
@@ -398,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
|
||||
{
|
||||
|
||||
+17
-3
@@ -19,8 +19,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.</summary>
|
||||
public string? FediverseSoftware { get; init; }
|
||||
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 LogoData => Service == SocialService.Fediverse
|
||||
@@ -238,7 +252,7 @@ public static class SocialServiceIcons
|
||||
}
|
||||
|
||||
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
|
||||
private static bool TryParseFediverse(string input, out string user, out string domain)
|
||||
public static bool TryParseFediverse(string input, out string user, out string domain)
|
||||
{
|
||||
user = domain = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 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) |
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,11 @@ public sealed class SocialLookupResult
|
||||
public interface ISocialValidator
|
||||
{
|
||||
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>
|
||||
@@ -35,14 +40,19 @@ 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 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();
|
||||
@@ -127,32 +137,67 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
/// 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.
|
||||
/// Failures return null — validation still succeeds, the icon just falls
|
||||
/// back to the generic fediverse glyph.
|
||||
/// 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
|
||||
{
|
||||
var software = await FetchSoftwareNameAsync(domain, ct);
|
||||
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, ct);
|
||||
if (resolved == null
|
||||
|| string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
|
||||
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;
|
||||
return await FetchSoftwareNameAsync(resolved, ct);
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return null; // caller checks ct.IsCancellationRequested and reports Canceled
|
||||
}
|
||||
catch
|
||||
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);
|
||||
|
||||
+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. 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 instead. 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).
|
||||
|
||||
@@ -106,7 +106,7 @@ 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 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
|
||||
@@ -115,6 +115,8 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
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
|
||||
@@ -440,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)
|
||||
|
||||
+12
-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.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -79,6 +80,14 @@ the guardrails — the "never do" list is there on purpose.
|
||||
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
|
||||
|
||||
+545
-47
@@ -14,6 +14,9 @@ using Microsoft.Win32;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
@@ -24,6 +27,8 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly YouTubeChatService _youtubeChat;
|
||||
private readonly DispatcherTimer _liveTimer;
|
||||
private readonly DispatcherTimer _volumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameAudioTimer;
|
||||
|
||||
private Scene? _activeScene;
|
||||
private SceneElement? _selectedElement;
|
||||
@@ -38,6 +43,14 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _micMuted;
|
||||
private double? _volumeBeforeMute;
|
||||
private string? _micSourceName;
|
||||
private MicStatus _micStatus = MicStatus.NotConnected;
|
||||
private double _gameAudioLevel;
|
||||
private bool _gameVolumeAdjusting;
|
||||
private bool _gameVolumeFlash;
|
||||
private double _gameVolume = 1.0;
|
||||
private bool _gameMuted;
|
||||
private double? _gameVolumeBeforeMute;
|
||||
private bool _isGameAudioBarVisible;
|
||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||
private StreamHealth _currentHealth = new();
|
||||
private string _streamTitle = string.Empty;
|
||||
@@ -48,7 +61,7 @@ public class MainViewModel : ViewModelBase
|
||||
private string _previewGlowBrush = "Transparent";
|
||||
private Thickness _previewGlowThickness = new(0);
|
||||
private string _liveElapsedText = "00:00:00";
|
||||
private double _livePulseOpacity = 1.0;
|
||||
private double _recDotPulse = 1.0;
|
||||
private TimeSpan _liveElapsed;
|
||||
|
||||
private bool _isSettingsOpen;
|
||||
@@ -77,14 +90,30 @@ public class MainViewModel : ViewModelBase
|
||||
private Webcam? _webcam;
|
||||
private string? _webcamError;
|
||||
private SocialsConfig? _socials;
|
||||
private VideoFrame? _socialBarFrame;
|
||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||
|
||||
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
|
||||
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
|
||||
// lifetime (started at startup, stopped on shutdown) so the footer meters
|
||||
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
|
||||
// feeds the game audio bar's meter. Private by design — the mixer surfaces
|
||||
// the levels + mic connection state to the UI.
|
||||
private readonly AudioMixer _audioMixer;
|
||||
|
||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
||||
private readonly Func<string?> _rtmpUrlProvider;
|
||||
private readonly FramePump _framePump;
|
||||
|
||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
||||
// shows at the bottom layer. One shared capture session per key — the
|
||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||
private readonly IFullScreenDetector _fullScreenDetector;
|
||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
||||
private readonly IGameAudioDetector _gameAudioDetector;
|
||||
private int? _lastForegroundFullScreenMonitor;
|
||||
private CancellationTokenSource? _deactivateCts;
|
||||
private ImageSource? _backdropImage;
|
||||
@@ -223,7 +252,10 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
OnPropertyChanged(nameof(IsLive));
|
||||
OnPropertyChanged(nameof(LiveIndicatorVisible));
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
OnPropertyChanged(nameof(ShowStartStream));
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
@@ -256,8 +288,9 @@ public class MainViewModel : ViewModelBase
|
||||
private set => SetProperty(ref _accountDisplayName, value);
|
||||
}
|
||||
|
||||
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
|
||||
// ─── only audio control is the mic: meter + volume + mute.) ───
|
||||
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
|
||||
// ─── visible; the game bar (meter + volume + mute) appears only while a
|
||||
// ─── full-screen game is producing sound. Both meters preview live. ───
|
||||
|
||||
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
|
||||
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
|
||||
@@ -279,7 +312,7 @@ public class MainViewModel : ViewModelBase
|
||||
/// level flashes on the bar) so the creator sees where they're setting it;
|
||||
/// otherwise the realtime live level scaled by volume (raising the volume
|
||||
/// moves ambient noise up the bar). Read-only meter.</summary>
|
||||
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevel * MicVolume);
|
||||
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume);
|
||||
|
||||
public double MeterFillWidth => MeterLevel * 288;
|
||||
|
||||
@@ -393,12 +426,178 @@ public class MainViewModel : ViewModelBase
|
||||
Owner = System.Windows.Application.Current?.MainWindow
|
||||
};
|
||||
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
|
||||
{
|
||||
MicSourceName = dialog.PickedDevice.DisplayName;
|
||||
_audioMixer.RestartMic(); // swap the live device immediately
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mic status dot (the MIC button) ───
|
||||
|
||||
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
|
||||
|
||||
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
|
||||
/// events and the startup device check (see StartMicCaptureAsync).</summary>
|
||||
public MicStatus MicStatus
|
||||
{
|
||||
get => _micStatus;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _micStatus, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(MicStatusBrush));
|
||||
OnPropertyChanged(nameof(MicStatusToolTip));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status dot: green = connected, yellow = problem with the requested
|
||||
/// connection, red = not connected (no device / not started).</summary>
|
||||
public SolidColorBrush MicStatusBrush => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => BarOnBrush,
|
||||
MicStatus.Problem => MicProblemBrush,
|
||||
_ => BarOffBrush,
|
||||
};
|
||||
|
||||
public string MicStatusToolTip => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => "Mic connected — click to change",
|
||||
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
|
||||
_ => "No mic connected — click to choose a microphone",
|
||||
};
|
||||
|
||||
// ─── Game audio bar (desktop/game): visible only while a full-screen game
|
||||
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
|
||||
// ─── the mic bar. ───
|
||||
|
||||
/// <summary>True while the game audio bar should be shown (driven by
|
||||
/// IGameAudioDetector via the poll timer).</summary>
|
||||
public bool IsGameAudioBarVisible
|
||||
{
|
||||
get => _isGameAudioBarVisible;
|
||||
private set => SetProperty(ref _isGameAudioBarVisible, value);
|
||||
}
|
||||
|
||||
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
|
||||
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
|
||||
public double GameAudioLevel
|
||||
{
|
||||
get => _gameAudioLevel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Displayed game meter level: 0 while muted; the volume position
|
||||
/// while the slider is dragged (or briefly after an unmute flash); otherwise
|
||||
/// the realtime live level scaled by volume.</summary>
|
||||
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
|
||||
|
||||
public double GameMeterFillWidth => GameMeterLevel * 288;
|
||||
|
||||
public string GameMeterBrush => GameMeterLevel switch
|
||||
{
|
||||
< 0.6 => "#22c55e",
|
||||
< 0.8 => "#eab308",
|
||||
_ => "#ef4444",
|
||||
};
|
||||
|
||||
public void SetGameVolumeAdjusting(bool adjusting)
|
||||
{
|
||||
if (adjusting)
|
||||
CancelGameVolumeFlash();
|
||||
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlash = true;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlashTimer.Start();
|
||||
}
|
||||
|
||||
private void EndGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
if (_gameVolumeFlash)
|
||||
{
|
||||
_gameVolumeFlash = false;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlash = false;
|
||||
}
|
||||
|
||||
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
|
||||
/// prior level is remembered so the speaker button can restore it.</summary>
|
||||
public double GameAudioVolume
|
||||
{
|
||||
get => _gameVolume;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (clamped == 0 && !_gameMuted)
|
||||
_gameVolumeBeforeMute ??= _gameVolume;
|
||||
if (SetProperty(ref _gameVolume, clamped))
|
||||
{
|
||||
var muted = clamped == 0;
|
||||
if (_gameMuted != muted)
|
||||
{
|
||||
_gameMuted = muted;
|
||||
OnPropertyChanged(nameof(GameMuted));
|
||||
OnPropertyChanged(nameof(GameMuteText));
|
||||
}
|
||||
if (!muted)
|
||||
_gameVolumeBeforeMute = null;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
|
||||
/// speaker can never disagree).</summary>
|
||||
public bool GameMuted => _gameMuted;
|
||||
|
||||
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
|
||||
|
||||
private void ToggleGameMute()
|
||||
{
|
||||
if (GameMuted)
|
||||
{
|
||||
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
|
||||
BeginGameVolumeFlash();
|
||||
}
|
||||
else
|
||||
{
|
||||
_gameVolumeBeforeMute = GameAudioVolume;
|
||||
GameAudioVolume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool LiveIndicatorVisible => IsLive;
|
||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
|
||||
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
|
||||
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
|
||||
public bool ShowStartStream => IsOffline;
|
||||
public bool ShowChatInactiveMessage => !IsLive;
|
||||
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
|
||||
@@ -479,7 +678,15 @@ public class MainViewModel : ViewModelBase
|
||||
public string StreamVisibility
|
||||
{
|
||||
get => _streamVisibility;
|
||||
set => SetProperty(ref _streamVisibility, value);
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _streamVisibility, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WindowTitle
|
||||
@@ -512,12 +719,6 @@ public class MainViewModel : ViewModelBase
|
||||
set => SetProperty(ref _liveElapsedText, value);
|
||||
}
|
||||
|
||||
public double LivePulseOpacity
|
||||
{
|
||||
get => _livePulseOpacity;
|
||||
set => SetProperty(ref _livePulseOpacity, value);
|
||||
}
|
||||
|
||||
// Overlay panels
|
||||
public bool IsSettingsOpen
|
||||
{
|
||||
@@ -730,19 +931,19 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
// Commands
|
||||
public ICommand AddSceneCommand { get; }
|
||||
public ICommand EditSceneCommand { get; }
|
||||
public ICommand RemoveSceneCommand { get; }
|
||||
public ICommand ToggleSceneVisibilityCommand { get; }
|
||||
public ICommand AddSourceCommand { get; }
|
||||
public ICommand AddWebcamCommand { get; }
|
||||
public ICommand AddImageCommand { get; }
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand EditElementCommand { get; }
|
||||
public ICommand ToggleElementVisibilityCommand { get; }
|
||||
public ICommand ChangeWebcamCommand { get; }
|
||||
public ICommand ShowWebcamCommand { get; }
|
||||
public ICommand ChangeCaptureCommand { get; }
|
||||
public ICommand RefreshCaptureCommand { get; }
|
||||
public ICommand SetBackdropDisplayCommand { get; }
|
||||
public ICommand ToggleMicMuteCommand { get; }
|
||||
public ICommand ToggleGameMuteCommand { get; }
|
||||
public ICommand OpenMicPickerCommand { get; }
|
||||
public ICommand OpenSocialDialogCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
@@ -779,6 +980,9 @@ public class MainViewModel : ViewModelBase
|
||||
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
|
||||
|
||||
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
|
||||
|
||||
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
||||
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
||||
|
||||
@@ -790,13 +994,12 @@ public class MainViewModel : ViewModelBase
|
||||
Scenes.CollectionChanged += OnScenesChanged;
|
||||
|
||||
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
|
||||
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
|
||||
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
|
||||
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
|
||||
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
|
||||
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
|
||||
AddImageCommand = new RelayCommand(_ => AddImage());
|
||||
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
||||
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
||||
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
|
||||
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
||||
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
||||
@@ -804,6 +1007,7 @@ public class MainViewModel : ViewModelBase
|
||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
|
||||
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
|
||||
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
||||
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||
@@ -832,9 +1036,27 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
|
||||
|
||||
_audioMixer = new AudioMixer(
|
||||
new WasapiMicAudioSource(() => MicSourceName),
|
||||
new WasapiLoopbackAudioSource(),
|
||||
message => AppLog.Write(message));
|
||||
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
||||
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
||||
_audioMixer.MicConnected += OnMicConnected;
|
||||
_audioMixer.MicFailed += OnMicFailed;
|
||||
_ = StartMicCaptureAsync();
|
||||
|
||||
_fullScreenDetector = new Win32FullScreenDetector();
|
||||
foreach (var display in _fullScreenDetector.GetDisplays())
|
||||
Displays.Add(display);
|
||||
|
||||
_gameAudioDetector = new GameAudioDetector(
|
||||
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
|
||||
() => (float)GameAudioLevel);
|
||||
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
|
||||
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
_gameAudioTimer.Tick += OnGameAudioPollTick;
|
||||
_gameAudioTimer.Start();
|
||||
_screenCaptureFactory = new ScreenCaptureSourceFactory(
|
||||
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
|
||||
_screenCaptureManager = new ScreenCaptureManager(
|
||||
@@ -844,6 +1066,18 @@ public class MainViewModel : ViewModelBase
|
||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
||||
|
||||
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
|
||||
_framePump = new FramePump(
|
||||
sceneProvider: () => ActiveScene,
|
||||
frameResolver: ResolveOutputFrame,
|
||||
compositorOptions: BuildCompositorOptions,
|
||||
encoderOptions: BuildEncoderOptions,
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
|
||||
_framePump.Failed += OnFramePumpFailed;
|
||||
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
|
||||
|
||||
LoadLayout();
|
||||
_ = LoadSavedSessionAsync();
|
||||
AppLog.Write("MainViewModel ctor end");
|
||||
@@ -935,6 +1169,8 @@ public class MainViewModel : ViewModelBase
|
||||
ReacquireWebcam();
|
||||
ReacquireScreenCaptures();
|
||||
_socials = _layoutStore.Socials;
|
||||
RenderSocialBarFrame();
|
||||
HealFediverseSoftwareInBackground();
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
@@ -1156,6 +1392,9 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_saveDebounce?.Stop();
|
||||
SaveLayoutNow();
|
||||
_gameAudioTimer.Stop();
|
||||
_audioMixer.Dispose();
|
||||
_framePump.Dispose();
|
||||
_cameraManager.Dispose();
|
||||
_screenCaptureManager.Dispose();
|
||||
_layoutStore.Dispose();
|
||||
@@ -1283,26 +1522,16 @@ public class MainViewModel : ViewModelBase
|
||||
ActiveScene = scene;
|
||||
}
|
||||
|
||||
private void BeginEditScene(Scene? scene)
|
||||
private void BeginEditElement(SceneElement? element)
|
||||
{
|
||||
if (scene == null) return;
|
||||
scene.IsEditing = true;
|
||||
if (element == null) return;
|
||||
element.IsEditing = true;
|
||||
}
|
||||
|
||||
private void ToggleSceneVisibility(Scene? scene)
|
||||
private void ToggleElementVisibility(SceneElement? element)
|
||||
{
|
||||
if (scene == null) return;
|
||||
scene.IsHidden = !scene.IsHidden;
|
||||
if (scene.IsHidden && ActiveScene == scene)
|
||||
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
|
||||
}
|
||||
|
||||
private void RemoveScene(Scene? scene)
|
||||
{
|
||||
if (scene == null) return;
|
||||
Scenes.Remove(scene);
|
||||
if (ActiveScene == scene)
|
||||
ActiveScene = Scenes.FirstOrDefault();
|
||||
if (element == null) return;
|
||||
element.IsVisible = !element.IsVisible;
|
||||
}
|
||||
|
||||
private void AddSource(object? parameter)
|
||||
@@ -1345,15 +1574,30 @@ public class MainViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
|
||||
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
|
||||
|
||||
scene.Elements.Add(new Source { Name = name, Type = sourceType });
|
||||
scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
UpdateActiveBackground();
|
||||
}
|
||||
|
||||
// Duplicate resource names get an incrementing suffix with no space: Image,
|
||||
// Image2, Image3… The next free number is derived from the names actually in
|
||||
// the scene, so deleting a middle resource never collides with a survivor.
|
||||
private static string NextSourceName(Scene scene, string baseName)
|
||||
{
|
||||
var taken = scene.Elements.OfType<Source>()
|
||||
.Select(s => s.Name)
|
||||
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|
||||
|| (n.Length > baseName.Length
|
||||
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(n.Substring(baseName.Length), out _)))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!taken.Contains(baseName)) return baseName;
|
||||
for (var i = 2; ; i++)
|
||||
if (!taken.Contains($"{baseName}{i}"))
|
||||
return $"{baseName}{i}";
|
||||
}
|
||||
|
||||
// Adds the webcam to the active scene. The creator ALWAYS picks from the
|
||||
// cameras Windows has registered — never silently resurrects the previous
|
||||
// camera (which is what happened after deleting one scene's webcam while
|
||||
@@ -1568,10 +1812,7 @@ public class MainViewModel : ViewModelBase
|
||||
var scene = ActiveScene;
|
||||
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
||||
|
||||
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
|
||||
var name = count == 0 ? "Image" : $"Image {count + 1}";
|
||||
|
||||
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
|
||||
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
|
||||
|
||||
var image = ImageCache.Get(assetId);
|
||||
if (image != null)
|
||||
@@ -1725,6 +1966,8 @@ public class MainViewModel : ViewModelBase
|
||||
? "ytLlive"
|
||||
: $"{dialog.StreamTitle} — ytLlive";
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
ResetHealth(StreamStatus.Streaming);
|
||||
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1732,6 +1975,10 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
WindowTitle = "ytLlive";
|
||||
ResetHealth(StreamStatus.Offline);
|
||||
// Audio capture is always-on (preview monitoring); only the frame pump
|
||||
// and the session stop here.
|
||||
_ = _framePump.StopAsync();
|
||||
// Graceful end completes the session = signs out (the DPAPI token is
|
||||
// cleared so the next Start Stream requires a fresh sign-in). A crash
|
||||
// never runs this, so the token survives and the creator stays signed in.
|
||||
@@ -1742,6 +1989,170 @@ public class MainViewModel : ViewModelBase
|
||||
AppLog.Write("Stream ended; session signed out");
|
||||
}
|
||||
|
||||
private void OnMicLevelChanged(float level)
|
||||
{
|
||||
// NAudio raises on its capture thread; marshal to the UI thread so the
|
||||
// meter binding updates safely.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => AudioLevel = level);
|
||||
else
|
||||
AudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnLoopbackLevelChanged(float level)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => GameAudioLevel = level);
|
||||
else
|
||||
GameAudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnMicConnected()
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
|
||||
else
|
||||
MicStatus = MicStatus.Connected;
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
// The mixer logs the failure detail; here we only flip the dot to yellow.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
|
||||
else
|
||||
MicStatus = MicStatus.Problem;
|
||||
}
|
||||
|
||||
/// <summary>Starts capture once at startup: with no mic device present the
|
||||
/// dot stays red and capture never starts; otherwise the mixer starts and
|
||||
/// raises MicConnected (green) or MicFailed (yellow).</summary>
|
||||
private async Task StartMicCaptureAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
|
||||
if (mics.Count == 0)
|
||||
{
|
||||
MicStatus = MicStatus.NotConnected;
|
||||
AppLog.Write("Mic: no capture devices found — mic capture not started");
|
||||
return;
|
||||
}
|
||||
_audioMixer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Mic: device check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
|
||||
|
||||
private void OnGameAudioPollTick(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_gameAudioDetector.Poll();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Game audio detection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
|
||||
// images/background by AssetId. A null frame leaves the element transparent.
|
||||
private VideoFrame? ResolveOutputFrame(SceneElement element)
|
||||
{
|
||||
return element switch
|
||||
{
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
private CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
return new CompositorOptions
|
||||
{
|
||||
SourceRectX = (int)Math.Round(OutputRectX),
|
||||
SourceRectY = (int)Math.Round(OutputRectY),
|
||||
SourceRectWidth = (int)Math.Round(OutputRectWidth),
|
||||
SourceRectHeight = (int)Math.Round(OutputRectHeight),
|
||||
OutputWidth = quality.Width,
|
||||
OutputHeight = quality.Height,
|
||||
};
|
||||
}
|
||||
|
||||
// Full encoder options for the current tier, or null when no RTMP URL is
|
||||
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
var quality = SelectedQuality;
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
Fps = quality.Fps,
|
||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
private void OnFramePumpFailed(object? sender, string message)
|
||||
{
|
||||
AppLog.Write($"Frame pump failed: {message}");
|
||||
if (IsLive) StreamStatus = StreamStatus.Error;
|
||||
}
|
||||
|
||||
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
||||
// duration) lands in the bottom bar. The stderr loop raises on a background
|
||||
// thread — marshal to the UI thread like the audio level handlers.
|
||||
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => ApplyHealth(health));
|
||||
else
|
||||
ApplyHealth(health);
|
||||
}
|
||||
|
||||
private void ApplyHealth(StreamHealth health)
|
||||
{
|
||||
CurrentHealth.Status = health.Status;
|
||||
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
|
||||
CurrentHealth.FPS = health.FPS;
|
||||
CurrentHealth.DroppedFrames = health.DroppedFrames;
|
||||
CurrentHealth.StreamDuration = health.StreamDuration;
|
||||
CurrentHealth.LastError = health.LastError;
|
||||
CurrentHealth.HealthMessage = health.HealthMessage;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
}
|
||||
|
||||
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
|
||||
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
|
||||
// on the tier's targets — ApplyStreamQuality sets them on pick).
|
||||
private void ResetHealth(StreamStatus status)
|
||||
{
|
||||
CurrentHealth.Status = status;
|
||||
CurrentHealth.DroppedFrames = 0;
|
||||
CurrentHealth.StreamDuration = TimeSpan.Zero;
|
||||
CurrentHealth.LastError = null;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
}
|
||||
|
||||
private void UpdateLiveVisuals()
|
||||
{
|
||||
var live = IsLive;
|
||||
@@ -1753,7 +2164,8 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
LiveElapsedText = "00:00:00";
|
||||
LivePulseOpacity = 1.0;
|
||||
_recDotPulse = 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
_liveTimer.Start();
|
||||
if (BrandFlashEnabled) StartBrandFlashTimer();
|
||||
}
|
||||
@@ -1764,7 +2176,8 @@ public class MainViewModel : ViewModelBase
|
||||
_brandFlashOffTimer.Stop();
|
||||
BrandFlashActive = false;
|
||||
LiveElapsedText = "00:00:00";
|
||||
LivePulseOpacity = 1.0;
|
||||
_recDotPulse = 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1797,11 +2210,89 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
}
|
||||
|
||||
// ─── Social bar ───
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
|
||||
/// fediverse entry that doesn't have one, so the bar shows the instance's real
|
||||
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
|
||||
/// leaves the glyph untouched. Returns handle → software for what was resolved —
|
||||
/// the caller applies + persists (the app hops to the dispatcher; a test applies
|
||||
/// directly).
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
|
||||
SocialsConfig socials,
|
||||
ISocialValidator validator,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
var resolved = new Dictionary<string, string>();
|
||||
if (socials == null || validator == null) return resolved;
|
||||
foreach (var entry in socials.Entries)
|
||||
{
|
||||
if (entry.Service != SocialService.Fediverse
|
||||
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
|
||||
continue;
|
||||
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
|
||||
continue;
|
||||
var software = await validator.ResolveFediverseSoftwareAsync(
|
||||
domain, System.Threading.CancellationToken.None);
|
||||
if (string.IsNullOrWhiteSpace(software)) continue;
|
||||
resolved[entry.Handle] = software;
|
||||
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
|
||||
private void HealFediverseSoftwareInBackground()
|
||||
{
|
||||
var socials = _socials;
|
||||
if (socials == null) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = await HealFediverseSoftwareAsync(
|
||||
socials, _socialValidator, m => AppLog.Write(m));
|
||||
if (resolved.Count == 0) return;
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var current = _socials;
|
||||
if (current == null) return;
|
||||
var applied = false;
|
||||
foreach (var entry in current.Entries)
|
||||
{
|
||||
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
|
||||
if (entry.FediverseSoftware == software) continue;
|
||||
entry.FediverseSoftware = software;
|
||||
applied = true;
|
||||
}
|
||||
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Socials heal failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
|
||||
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
|
||||
/// read it from its own thread. Null when the bar is off or empty.
|
||||
/// </summary>
|
||||
private void RenderSocialBarFrame()
|
||||
{
|
||||
_socialBarFrame = _socials != null && _socials.BarEnabled
|
||||
? SocialBarRenderer.Render(_socials.Entries)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
||||
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
||||
@@ -1825,13 +2316,14 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
private void NotifySocialsChanged()
|
||||
{
|
||||
RenderSocialBarFrame();
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
/// <summary>Drag release snaps the bar to the closest edge; called by the preview drag.</summary>
|
||||
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
|
||||
public void SetSocialBarPosition(SocialBarPosition position)
|
||||
{
|
||||
if (_socials == null || _socials.BarPosition == position) return;
|
||||
@@ -1840,6 +2332,12 @@ public class MainViewModel : ViewModelBase
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
|
||||
public void ToggleSocialBarPosition()
|
||||
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
|
||||
? SocialBarPosition.Bottom
|
||||
: SocialBarPosition.Top);
|
||||
|
||||
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
||||
private async Task SignOutYouTubeAsync()
|
||||
{
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -91,7 +91,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|------|------|
|
||||
| `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)** |
|
||||
| `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)
|
||||
@@ -114,7 +114,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
- `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 + 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,11 +338,124 @@ 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).
|
||||
|
||||
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md)
|
||||
### 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, top/bottom
|
||||
snap-drag (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
|
||||
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,
|
||||
@@ -356,6 +477,26 @@ honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodein
|
||||
`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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -16,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()
|
||||
{
|
||||
|
||||
@@ -103,6 +103,39 @@ public class SocialValidatorTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests
|
||||
{
|
||||
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++;
|
||||
@@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests
|
||||
|
||||
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
|
||||
|
||||
@@ -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