Compare commits

...

10 Commits

Author SHA1 Message Date
gramps d90b5ded0d TASK 7 UI polish batch: scene/source row cleanup, dedup naming, full social handles — scenes are now pure selection rows (edit/trash/visibility icons and inline rename removed with EditSceneCommand/RemoveSceneCommand/ToggleSceneVisibilityCommand + Scene.IsEditing); source rows gained the trio (new EditElementCommand → inline rename via SceneElement.IsEditing, ToggleElementVisibilityCommand → eye flips IsVisible, open/slashed style rebound, hidden rows dim to 45%); duplicate resource names get a no-space incrementing suffix via shared NextSourceName (Image, Image2, Image3…) derived from actual names so deletions never collide (AddSource + AddReusedImage); social bar renders the full validated handle (MaxWidth=200 + TextTrimming removed from SocialBarRenderer and the preview template); side panels stay fixed 220/300; focus-loss capture lag documented in ai.md as a known OS limit (deferred) — the two real-App tests now share RealAppHost, a dedicated STA thread owning the single WPF App, instead of each calling new App(); new SourceNamingTests integration test (Text/Text2/Text3, delete-middle re-add no-collision) — 170 tests passing, 0 warnings 2026-08-13 18:50:56 -07:00
gramps 8292663791 TASK 4 ship step 6: health stats land in the bottom bar — FramePump.HealthUpdated is now wired to the VM (OnFramePumpHealthUpdated marshals onto the UI thread — the encoder's stderr loop raises on a background thread — and copies the parsed bitrate/FPS/dropped/duration into CurrentHealth, the bottom bar's existing binding); ResetHealth zeroes dropped frames + elapsed 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 shows real encoder values once TASK 5 fills _rtmpUrlProvider (until then the pump skips the encoder) — 169 tests passing, 0 warnings 2026-08-13 15:56:29 -07:00
gramps dc29adf9bb TASK 4 audio round 3: mic mute icon + top-center REC sign + voice-filter to-do — the mic bar gained a second 16px clickable glyph (microphone, red + slash when muted) between the meter and the speaker so both mute toggles sit adjacent with spacing, reusing the same ToggleMicMuteCommand handler (no VM/code-behind change); the top-center LIVE badge became an always-visible REC sign driven by new VM props (RecDotBrush/RecTextBrush/RecDotOpacity/IsLivePrivate): dark gray dot + dim REC offline, bright red + REC + elapsed while live, darker red when live with a private stream (driven by the dialog's chosen StreamVisibility; the stream service still forces public — ship step 7), pulsing while live; LiveIndicatorVisible/LivePulseOpacity removed; task 22 queued (bass boost, treble, noise suppression, compressor on the mic channel, KISS, always on); HANDOFF documents the mic status contract (red until a mic resource actually connects) — 169 tests passing, 0 warnings 2026-08-13 12:13:00 -07:00
gramps 18abe4210f TASK 4 audio fixes round 2: dB-scaled meters, game bar moved into the preview, Notepad About button replaced by the logo → in-app About — AudioLevelMeter.ToDisplay maps -60..0 dBFS onto 0..1 (real speech/game RMS ~0.01..0.1 linear left the flat scale dead; both meters now scale through it before volume); the game audio bar moved OUT of the footer (back to two rows) and is now a bottom-center dark chip overlaid on the preview window — monitoring UI only, never on the live output; the top-bar About button (opened THIRD-PARTY-NOTICES.txt in the OS viewer) removed, the ytLlive wordmark now opens the in-app About overlay, and task 21 queued (real logo + creator-hub About with premium/coffee/socials links + in-app scrolling licensing panel) — 169 tests passing, 0 warnings 2026-08-13 11:56:22 -07:00
gramps 9f9ed34627 TASK 4 audio follow-up: game audio bar + mic status dot + always-on capture — the footer's second audio control (desktop/game, a mirror of the mic bar: meter + mute + volume) appears only while a full-screen game is producing sound (IGameAudioDetector seam + GameAudioHysteresis: show ~500ms of fullscreen+sound, hide ~1s after leaving fullscreen, silence never hides an active bar; VM polls on a 250ms timer); capture now runs for the app's lifetime so both meters preview live (started at startup via StartMicCaptureAsync, disposed in Shutdown — no longer go-live driven); MIC label is a button with a status dot (Models/MicStatus: green via the source Started event, yellow = mic problem, red = no device); PickMicrophone swaps the live device immediately via AudioMixer.RestartMic; fixed a latent ?.Invoke(meter.Push(...)) short-circuit that skipped the meter update when nothing subscribed — 167 tests passing, 0 warnings 2026-08-13 11:29:44 -07:00
gramps ea250c02a6 Social bar position is a click-toggle, not a drag — the drag-snap misbehaved for shaky hands (snapped up but wouldn't come back down; jitter around the deadzone). Clicking the bar now flips it top/bottom via ToggleSocialBarPosition; SocialBarSnap and its units removed — 153 tests passing, 0 warnings 2026-08-13 11:04:32 -07:00
gramps ac60a26d82 TASK 4 ship step 5.5: social bar bug fixes + bar on the live output — direction-snap drag (SocialBarSnap.Decide + ClearValue, local Canvas.Top was overriding the binding), fediverse nodeinfo subdomain probing + load-time heal (settable FediverseSoftware), SocialBarRenderer strip blitted above the flash via a re-read FramePump socialBar seam — 155 tests passing, 0 warnings 2026-08-13 09:29:58 -07:00
gramps e72ba71165 TASK 4 ship step 5: live frame pipeline — FramePump paces the active scene's composite into the encoder, ScreenCaptureManager.GetLatestFrame, MainViewModel resolver/option-builder/pump wiring, FramePumpTests (7) + GetLatestFrame test — 147 tests passing, 0 warnings 2026-08-13 08:57:56 -07:00
gramps 58c0f8e8c4 TASK 4 ship step 4: WASAPI audio capture — NAudio loopback + mic behind an IAudioSource seam, AudioMixer driving AudioLevel while live, pure level meter + WaveToFloat, NAudio.Wasapi 2.2.1 (MIT, notices item 9) — 139 tests passing, 0 warnings 2026-08-12 21:18:07 -07:00
gramps ba427e85e1 TASK 4 ship step 3: encoder + RTMP push — FFmpeg subprocess with probed H.264 picker, BGRA stdin feed, stderr health parsing, graceful stop — 122 tests passing, 0 warnings 2026-08-12 20:32:24 -07:00
51 changed files with 4210 additions and 339 deletions
+71 -61
View File
@@ -5,71 +5,81 @@
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session > a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
> end, compaction, or any interruption. > 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`, - **Branch:** `main`, in sync with `origin/main`.
18 files, +1878/321, 112 tests passing, 0 warnings). Working tree is **dirty - **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
with the post-push docs backfill only** (ai.md, README.md, TASKS.md schema 1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
section, THIRD-PARTY-NOTICES.txt) — ready to commit when the user says so. rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
- **Finished this session (round 3):** user's live-test report said the mastodon persisted and still dims a hidden row to 45%. Removed dead surface:
icon still didn't show for `@gramps@llamachile.tube`. Root cause: the identity `EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
domain `llamachile.tube` is YunoHost-SSO-gated — `/.well-known/nodeinfo`, `BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
`/@gramps`, and webfinger all answer with the SSO login page, so nodeinfo 2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
returned nothing and the icon fell back to the honeycomb glyph. The real `EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
instance lives at `mastodon.llamachile.tube`, and only the bare root lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
`https://llamachile.tube/` 302s to it ("default app" redirect). Fix: the eye style now binds `IsVisible` and hidden rows dim to 45%.
`HttpSocialValidator.TryFetchFediverseSoftwareAsync` now, when nodeinfo on the 3. **Duplicate naming:** shared `NextSourceName(scene, baseName)``Image`, `Image2`,
identity domain fails, follows the root redirect (`ResolveInstanceHostAsync`, `Image3`… (no space), next free number derived from actual names so deletions never
reads `resp.RequestMessage.RequestUri.Host`) and re-runs the nodeinfo lookup collide. Used by both `AddSource` and `AddReusedImage`.
on the resolved host. New test `FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware`. 4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
Build: **0 warnings**. Tests: **112 passing**. `SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
- **What landed (all rounds this session):** 5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
1. **Fediverse icon resolution**`DetectService` maps `@user@domain` to the on resize; the preview absorbs it. No change.
new `SocialService.Fediverse` (was `Link`/chain icon). On validate, 6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
`HttpSocialValidator` best-effort GETs `https://{domain}/.well-known/nodeinfo`, throttling when unfocused + GPU readback contention + the `_framePending` /
follows the first nodeinfo `links[].href`, reads `software.name`, and returns `DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
it in `SocialLookupResult.FediverseSoftware`. `SocialEntry`/`SocialSlotViewModel` - **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
carry `FediverseSoftware`; `SocialServiceIcons.LogoDataForFediverse(software)` share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
maps it to a bundled logo (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/ serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
firefish — Simple Icons CC0), falling back to the `FediverseIconData` that calls `new App()` directly again — marshal onto `RealAppHost` instead.
honeycomb glyph for unknown software (GoToSocial/Sharkey/Akkoma aren't in - **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
Simple Icons). Nodeinfo failure still validates — the glyph falls back. (commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (`IsEditing`),
2. **Redirect resolution (round 3)** — identity domains that 302 their root `Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
to the real instance (YunoHost default-app subdomains) resolve software via (new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
the root redirect when identity-domain nodeinfo is SSO-blocked. (now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
3. **Persistence** — new `SocialEntry.Software TEXT` column via - **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
`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.
- **Landmines:** - **Landmines:**
- `LayoutStore.Socials` is only populated by `Load()` — tests must call - Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
`store.Load()` before asserting it. - Never set a local `Canvas.SetTop` on the social bar — a local value permanently
- `DetectService("justaname")`**Website** with empty handle; only overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
unparseable input or `@user@domain` yields Link/Fediverse. - `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
- Dialog sign-in provider is `Func<Task<YouTubeChannel?>>`; test fakes must `OnLoopbackSample` compute the level first, then raise the event — a
return `Task.FromResult` (sync-completed) so the fire-and-forget command `?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
settles before the next assert. nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
- `SocialBarBottomTop = 1040` literal lives in `MainWindow.xaml.cs` (the VM's - `MicConnected` comes from the source `Started` event, raised right after
`MasterFrameHeight` is private). `StartRecording()` succeeds — tests must `MarkStarted()` the fake source
- `Cancel_AbortsInFlightValidation_WithoutMutatingSlot` relies on the before asserting connection state.
BlockingValidator's gate completing synchronously (no - The mic dot is red until a resource connects (see contract below) — green
`RunContinuationsAsynchronously`) — the assertions run after only after `Started`, yellow on `Failed`.
`Gate.TrySetResult` returns because the awaited continuation executes inline. - Zero mic devices at startup = red dot AND the mixer is never started, so
- Nodeinfo cancellation test cancels mid-lookup via a counting stub; the loopback + the game bar can't run either (no capture at all) — acceptable.
post-fetch `ct.IsCancellationRequested` check is what reports `Canceled`. - The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
- Round-3 redirect fallback fires only when identity-domain nodeinfo fails; `OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
the root follow happens automatically (HttpClient default auto-redirect), - The pump reads the active scene on a background thread while the UI can still
and `RequestMessage.RequestUri.Host` is read from the final response. edit it — a concurrent-mutation exception is contained (logged + `Failed` +
- **Next step:** TASK 4 ship step 3 — the encoder + RTMP push (FFmpeg subprocess: the pump stops), not a crash.
frames via stdin, stderr health parsing, FLV mux + push to the cached reusable - `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
stream's ingestion URL). Nothing else queued — do not expand the task queue on loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
your own. 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`; - **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`); 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 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`. `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
+166 -94
View File
@@ -49,21 +49,21 @@
</DrawingImage.Drawing> </DrawingImage.Drawing>
</DrawingImage> </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"> <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"/> <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> <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"/> <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> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}"> <Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="ToolTip" Value="Hide Scene"/> <Setter Property="ToolTip" Value="Hide Source"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True"> <DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="ToolTip" Value="Show Scene"/> <Setter Property="ToolTip" Value="Show Source"/>
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -86,27 +86,32 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Logo / Brand --> <!-- Logo / Brand — clicking opens the in-app About overlay -->
<TextBlock Grid.Column="0" Text="ytLlive" <Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
Foreground="#e94560" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
VerticalAlignment="Center" Margin="0,0,24,0"/> 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" <StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center">
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}"> <Ellipse Width="12" Height="12" VerticalAlignment="Center"
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center" Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
Opacity="{Binding LivePulseOpacity}"/> <TextBlock Text="REC" FontSize="16" FontWeight="Bold"
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold" Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14" <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> </StackPanel>
<!-- Single three-state action button --> <!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <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" <Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}" ToolTip="{Binding AccountDisplayName}"
@@ -188,64 +193,8 @@
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<Grid Margin="0,2"> <Grid Margin="0,2">
<Grid.ColumnDefinitions> <TextBlock Text="{Binding Name}" VerticalAlignment="Center"
<ColumnDefinition Width="*"/> Padding="4,2" TextTrimming="CharacterEllipsis"/>
<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>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@@ -282,6 +231,11 @@
<Helpers:FocusPreservingListBox.ItemContainerStyle> <Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}"> <Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style> </Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle> </Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
@@ -290,6 +244,8 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.ContextMenu> <Grid.ContextMenu>
@@ -305,9 +261,51 @@
</Grid.ContextMenu> </Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" <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}}" Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}" CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}"> Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
@@ -654,13 +652,12 @@
<!-- Social bar: one centered horizontal row at the top or bottom of <!-- Social bar: one centered horizontal row at the top or bottom of
the frame, green glow while enabled. Global resource — never a 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}" <Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
Background="Transparent" Background="Transparent"
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}" Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
PreviewMouseLeftButtonDown="SocialBar_PreviewMouseLeftButtonDown" MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
PreviewMouseMove="SocialBar_PreviewMouseMove" Cursor="Hand">
PreviewMouseLeftButtonUp="SocialBar_PreviewMouseLeftButtonUp">
<Grid.Effect> <Grid.Effect>
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}" <DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/> BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
@@ -680,9 +677,7 @@
Stretch="Uniform" VerticalAlignment="Center"/> Stretch="Uniform" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Handle}" Foreground="White" <TextBlock Text="{Binding Handle}" Foreground="White"
FontSize="14" VerticalAlignment="Center" FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0" Margin="8,0,0,0"/>
TextTrimming="CharacterEllipsis"
MaxWidth="200"/>
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -745,6 +740,63 @@
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/> Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
</Border> </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>
</Grid> </Grid>
</Border> </Border>
@@ -800,9 +852,10 @@
<!-- Line 1: Social controls on the LEFT (under scenes/sources), <!-- Line 1: Social controls on the LEFT (under scenes/sources),
sound meter + volume control CENTERED beneath the preview panel. sound meter + volume control CENTERED beneath the preview panel.
Audio is KISS: desktop/game audio is automatic ("it just is" — Audio is KISS: desktop/game audio is automatic (WASAPI loopback
WASAPI loopback at unity, zero UI); the creator's only audio at unity); the mic meter is the creator's only footer audio
control is the mic — meter, volume, mute. --> 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 Grid.Row="0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/> <ColumnDefinition Width="220"/>
@@ -825,13 +878,19 @@
</Button> </Button>
</StackPanel> </StackPanel>
<!-- Meter + volume: centered under the preview panel --> <!-- Mic meter + volume: centered under the preview panel -->
<StackPanel Grid.Column="1" Orientation="Horizontal" <StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" <Button Command="{Binding OpenMicPickerCommand}"
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand" Style="{StaticResource YtButtonSecondary}" Padding="12,4"
ToolTip="Choose the microphone (voice source)" FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/> 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 <!-- Sound meter: muted track with a ruler scale, zone tints at
the yellow (60%) and red (80%) starts. READ-ONLY realtime the yellow (60%) and red (80%) starts. READ-ONLY realtime
level display (fill = live level scaled by volume). --> level display (fill = live level scaled by volume). -->
@@ -863,6 +922,19 @@
VerticalAlignment="Stretch"/> VerticalAlignment="Stretch"/>
</Grid> </Grid>
</Border> </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" <Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}" VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp"> MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
+39 -65
View File
@@ -1,7 +1,5 @@
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -65,7 +63,7 @@ public partial class MainWindow : Window
: null; : null;
} }
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{ {
if (sender is TextBox { IsVisible: true } box) 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) private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
if (sender is FrameworkElement { DataContext: MainViewModel vm }) if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -107,6 +99,24 @@ public partial class MainWindow : Window
vm.SetVolumeAdjusting(false); 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) private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{ {
if (sender is FrameworkElement { DataContext: MainViewModel vm }) 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) private void GearButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) 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) private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) 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) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
// ─── Social bar drag: vertical only, snaps to top/bottom on release ─── // ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
private const double SocialBarBottomTop = 1040; // unusable for shaky hands (jitter around the direction deadzone), so the bar
private bool _isDraggingSocialBar; // rides on {Binding SocialBarTop} alone and a click flips it. ───
private double _socialBarGrabOffsetY; private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
private void SocialBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
var bar = (FrameworkElement)sender; _viewModel.ToggleSocialBarPosition();
_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);
e.Handled = true; e.Handled = true;
} }
@@ -398,24 +372,24 @@ public partial class MainWindow : Window
return null; 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) if (e.Key is Key.Enter or Key.Escape)
{ {
CommitSceneEdit(sender); CommitSourceEdit(sender);
e.Handled = true; 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 }) if (sender is FrameworkElement { DataContext: SceneElement element })
scene.IsEditing = false; element.IsEditing = false;
} }
// ─── List drag-to-reorder (scenes list + sources list) ─── // ─── List drag-to-reorder (scenes list + sources list) ───
+13
View File
@@ -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
}
-7
View File
@@ -9,7 +9,6 @@ public class Scene : INotifyPropertyChanged
public string Id { get; init; } = Guid.NewGuid().ToString(); public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty; private string _name = string.Empty;
private bool _isEditing;
private bool _isHidden; private bool _isHidden;
private bool _hasBackdrop; private bool _hasBackdrop;
private bool _hasSocialBar; private bool _hasSocialBar;
@@ -20,12 +19,6 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _name, value); set => Set(ref _name, value);
} }
public bool IsEditing
{
get => _isEditing;
set => Set(ref _isEditing, value);
}
public bool IsHidden public bool IsHidden
{ {
get => _isHidden; get => _isHidden;
+3
View File
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
private bool _isVisible = true; private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); } 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; private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape public ClipShape ClipShape
{ {
+17 -3
View File
@@ -19,8 +19,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
public string Handle { get; init; } = string.Empty; public string Handle { get; init; } = string.Empty;
public string ProfileUrl { get; init; } = string.Empty; public string ProfileUrl { get; init; } = string.Empty;
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.</summary> private string? _fediverseSoftware;
public string? FediverseSoftware { get; init; }
/// <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 ServiceName => Service.ToString();
public string LogoData => Service == SocialService.Fediverse 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> /// <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; user = domain = string.Empty;
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false; if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
+1
View File
@@ -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` | | `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 | | `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 | | `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) | | `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) | | `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) |
+49
View File
@@ -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;
}
+133
View File
@@ -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}");
}
}
+20
View File
@@ -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;
}
}
+27
View File
@@ -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);
}
}
+105
View File
@@ -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);
}
}
+41
View File
@@ -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 -9
View File
@@ -18,13 +18,16 @@ public sealed class SceneCompositor
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back → /// 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 → /// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) → /// 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> /// </summary>
public VideoFrame Render( public VideoFrame Render(
Scene scene, Scene scene,
Func<SceneElement, VideoFrame?> frameFor, Func<SceneElement, VideoFrame?> frameFor,
VideoFrame? flashFrame, VideoFrame? flashFrame,
CompositorOptions options) CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0)
{ {
if (scene == null) throw new ArgumentNullException(nameof(scene)); if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor)); if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -79,7 +82,10 @@ public sealed class SceneCompositor
} }
if (flashFrame != null) 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( return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight); 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> /// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash) /// 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 y = 0; y < dstH; y++)
{ {
for (var x = 0; x < dstW; x++) for (var x = 0; x < dstW; x++)
{ {
var sx = x + options.SourceRectX; var sx = x + options.SourceRectX - sx0;
var sy = y + options.SourceRectY; var sy = y + options.SourceRectY - sy0;
if (sx >= flash.Width || sy >= flash.Height) continue; if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy); var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f); BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
} }
} }
+105
View File
@@ -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);
}
}
+27
View File
@@ -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/&lt;key&gt;</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;
}
+46
View File
@@ -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,
];
}
}
+263
View File
@@ -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);
}
}
+35
View File
@@ -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];
}
}
+38
View File
@@ -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();
}
+47
View File
@@ -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);
}
}
+272
View File
@@ -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();
}
}
+26
View File
@@ -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);
}
+25
View File
@@ -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);
}
+46
View File
@@ -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);
}
}
+57
View File
@@ -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;
}
}
}
}
+20
View File
@@ -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();
}
+9
View File
@@ -156,6 +156,15 @@ public sealed class ScreenCaptureManager : IDisposable
toStop.PreviewBitmap = null; 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() public void Dispose()
{ {
List<CaptureSession> sessions; List<CaptureSession> sessions;
+54 -9
View File
@@ -23,6 +23,11 @@ public sealed class SocialLookupResult
public interface ISocialValidator public interface ISocialValidator
{ {
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct); 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> /// <summary>
@@ -35,14 +40,19 @@ public interface ISocialValidator
public sealed class HttpSocialValidator : ISocialValidator public sealed class HttpSocialValidator : ISocialValidator
{ {
private readonly HttpClient _client; 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 = client ?? new HttpClient();
_client.DefaultRequestHeaders.UserAgent.ParseAdd( _client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; ytLlive social validator)"); "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) public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{ {
var input = handleOrUrl.Trim(); 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 /// 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), /// 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. /// the bare root 302s to the real instance — follow it and ask that host.
/// Failures return null — validation still succeeds, the icon just falls /// If neither answers, probe well-known subdomains (mastodon.example.com)
/// back to the generic fediverse glyph. /// 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> /// </summary>
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct) private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
{ {
try 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; if (software != null) return software;
var resolved = await ResolveInstanceHostAsync(domain, ct); var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
if (resolved == null if (resolved != null
|| string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase)) && !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 null;
return await FetchSoftwareNameAsync(resolved, ct);
} }
catch (System.OperationCanceledException) when (ct.IsCancellationRequested) catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
{ {
return null; // caller checks ct.IsCancellationRequested and reports Canceled 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; 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) private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
{ {
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct); var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
+23 -3
View File
@@ -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` | | `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 | | `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) | | `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/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/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`) | | `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 | | `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 | | `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 |
| `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) | | `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) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+192 -9
View File
@@ -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…" 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 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 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) 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. 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 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 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 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`) 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) ### 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) 1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
2. ✅ **Ship step 2 — the FFmpeg locator 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 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** loopback (desktop/game) + the picked mic feeding `AudioLevel` so the realtime meter comes alive (req 7) 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**`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder 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** bitrate, FPS, dropped frames reported live in the bottom bar (req 5) 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) 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 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. 1. `IFfmpegLocator.cs` — the seam.
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`. 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 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 output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
OS viewer). 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 **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 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 **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 **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 staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**. guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a 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 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**. 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 ## 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) ## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+12 -3
View File
@@ -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 are NOT optional: they attach because this product ships or automates the
download of these components. download of these components.
If this file changes, update it here AND in the app's About screen (it opens If this file changes, update it here AND in the app's About screen (the About
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
the guardrails — the "never do" list is there on purpose. 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" obligations; it is listed here per this file's "list everything"
policy. 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 MISCELLANEOUS
+545 -47
View File
@@ -14,6 +14,9 @@ using Microsoft.Win32;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
@@ -24,6 +27,8 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeChatService _youtubeChat; private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer; private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _gameAudioTimer;
private Scene? _activeScene; private Scene? _activeScene;
private SceneElement? _selectedElement; private SceneElement? _selectedElement;
@@ -38,6 +43,14 @@ public class MainViewModel : ViewModelBase
private bool _micMuted; private bool _micMuted;
private double? _volumeBeforeMute; private double? _volumeBeforeMute;
private string? _micSourceName; 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 StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new(); private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty; private string _streamTitle = string.Empty;
@@ -48,7 +61,7 @@ public class MainViewModel : ViewModelBase
private string _previewGlowBrush = "Transparent"; private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0); private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00"; private string _liveElapsedText = "00:00:00";
private double _livePulseOpacity = 1.0; private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed; private TimeSpan _liveElapsed;
private bool _isSettingsOpen; private bool _isSettingsOpen;
@@ -77,14 +90,30 @@ public class MainViewModel : ViewModelBase
private Webcam? _webcam; private Webcam? _webcam;
private string? _webcamError; private string? _webcamError;
private SocialsConfig? _socials; private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame;
private readonly IMicrophoneEnumerator _microphoneEnumerator; 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 // Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the // shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager. // ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector; private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly IGameAudioDetector _gameAudioDetector;
private int? _lastForegroundFullScreenMonitor; private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts; private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage; private ImageSource? _backdropImage;
@@ -223,7 +252,10 @@ public class MainViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(LiveIndicatorVisible)); OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -256,8 +288,9 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _accountDisplayName, value); private set => SetProperty(ref _accountDisplayName, value);
} }
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's // ─── Audio: the mic bar (meter + volume + mute + status dot) is always
// ─── only audio control is the mic: meter + volume + mute.) ─── // ─── 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 /// <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> /// 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; /// 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 /// otherwise the realtime live level scaled by volume (raising the volume
/// moves ambient noise up the bar). Read-only meter.</summary> /// 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; public double MeterFillWidth => MeterLevel * 288;
@@ -393,12 +426,178 @@ public class MainViewModel : ViewModelBase
Owner = System.Windows.Application.Current?.MainWindow Owner = System.Windows.Application.Current?.MainWindow
}; };
if (dialog.ShowDialog() == true && dialog.PickedDevice != null) if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName; 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 IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming; 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 ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
@@ -479,7 +678,15 @@ public class MainViewModel : ViewModelBase
public string StreamVisibility public string StreamVisibility
{ {
get => _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 public string WindowTitle
@@ -512,12 +719,6 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _liveElapsedText, value); set => SetProperty(ref _liveElapsedText, value);
} }
public double LivePulseOpacity
{
get => _livePulseOpacity;
set => SetProperty(ref _livePulseOpacity, value);
}
// Overlay panels // Overlay panels
public bool IsSettingsOpen public bool IsSettingsOpen
{ {
@@ -730,19 +931,19 @@ public class MainViewModel : ViewModelBase
// Commands // Commands
public ICommand AddSceneCommand { get; } public ICommand AddSceneCommand { get; }
public ICommand EditSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
public ICommand ToggleSceneVisibilityCommand { get; }
public ICommand AddSourceCommand { get; } public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; } public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; } public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; } public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; } public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; } public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; } public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; } public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; } public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; } public ICommand OpenSocialDialogCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
@@ -779,6 +980,9 @@ public class MainViewModel : ViewModelBase
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash(); _volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; _saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow(); _saveDebounce.Tick += (_, _) => SaveLayoutNow();
@@ -790,13 +994,12 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged; Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty)); 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)); AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync()); AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage()); AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement)); 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); ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene); ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog()); OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
@@ -804,6 +1007,7 @@ public class MainViewModel : ViewModelBase
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture()); RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo)); SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute()); ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone()); OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings")); OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug")); OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
@@ -832,9 +1036,27 @@ public class MainViewModel : ViewModelBase
_microphoneEnumerator = new WinRtMicrophoneEnumerator(); _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(); _fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays()) foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display); 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( _screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle); () => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager( _screenCaptureManager = new ScreenCaptureManager(
@@ -844,6 +1066,18 @@ public class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += (key, message) => _screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {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(); LoadLayout();
_ = LoadSavedSessionAsync(); _ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end"); AppLog.Write("MainViewModel ctor end");
@@ -935,6 +1169,8 @@ public class MainViewModel : ViewModelBase
ReacquireWebcam(); ReacquireWebcam();
ReacquireScreenCaptures(); ReacquireScreenCaptures();
_socials = _layoutStore.Socials; _socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush)); OnPropertyChanged(nameof(SocialBarDotBrush));
@@ -1156,6 +1392,9 @@ public class MainViewModel : ViewModelBase
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
SaveLayoutNow(); SaveLayoutNow();
_gameAudioTimer.Stop();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose(); _cameraManager.Dispose();
_screenCaptureManager.Dispose(); _screenCaptureManager.Dispose();
_layoutStore.Dispose(); _layoutStore.Dispose();
@@ -1283,26 +1522,16 @@ public class MainViewModel : ViewModelBase
ActiveScene = scene; ActiveScene = scene;
} }
private void BeginEditScene(Scene? scene) private void BeginEditElement(SceneElement? element)
{ {
if (scene == null) return; if (element == null) return;
scene.IsEditing = true; element.IsEditing = true;
} }
private void ToggleSceneVisibility(Scene? scene) private void ToggleElementVisibility(SceneElement? element)
{ {
if (scene == null) return; if (element == null) return;
scene.IsHidden = !scene.IsHidden; element.IsVisible = !element.IsVisible;
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();
} }
private void AddSource(object? parameter) private void AddSource(object? parameter)
@@ -1345,15 +1574,30 @@ public class MainViewModel : ViewModelBase
return; return;
} }
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType); scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
scene.Elements.Add(new Source { Name = name, Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); 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 // Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous // cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while // camera (which is what happened after deleting one scene's webcam while
@@ -1568,10 +1812,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return; if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image); var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId); var image = ImageCache.Get(assetId);
if (image != null) if (image != null)
@@ -1725,6 +1966,8 @@ public class MainViewModel : ViewModelBase
? "ytLlive" ? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive"; : $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming; 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; StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive"; 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 // Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash // 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. // 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"); 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() private void UpdateLiveVisuals()
{ {
var live = IsLive; var live = IsLive;
@@ -1753,7 +2164,8 @@ public class MainViewModel : ViewModelBase
{ {
_liveElapsed = TimeSpan.Zero; _liveElapsed = TimeSpan.Zero;
LiveElapsedText = "00:00:00"; LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0; _recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
_liveTimer.Start(); _liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer(); if (BrandFlashEnabled) StartBrandFlashTimer();
} }
@@ -1764,7 +2176,8 @@ public class MainViewModel : ViewModelBase
_brandFlashOffTimer.Stop(); _brandFlashOffTimer.Stop();
BrandFlashActive = false; BrandFlashActive = false;
LiveElapsedText = "00:00:00"; 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)); _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss"); 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 ─── // ─── 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> /// <summary>
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate, /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
/// validation). On save the working copy replaces <see cref="_socials"/>. /// validation). On save the working copy replaces <see cref="_socials"/>.
@@ -1825,13 +2316,14 @@ public class MainViewModel : ViewModelBase
private void NotifySocialsChanged() private void NotifySocialsChanged()
{ {
RenderSocialBarFrame();
OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush)); OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave(); 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) public void SetSocialBarPosition(SocialBarPosition position)
{ {
if (_socials == null || _socials.BarPosition == position) return; if (_socials == null || _socials.BarPosition == position) return;
@@ -1840,6 +2332,12 @@ public class MainViewModel : ViewModelBase
ScheduleSave(); 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> /// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
private async Task SignOutYouTubeAsync() private async Task SignOutYouTubeAsync()
{ {
+1 -1
View File
File diff suppressed because one or more lines are too long
+147 -6
View File
@@ -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** | | `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** | | `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 | | `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`) | | `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) | | `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 - `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) - `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) - ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet - 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) - 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`) - `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) - 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 - `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 - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1) ### 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 - **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 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. 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 - **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task. to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders — - **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 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). 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 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 output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
snap-drag (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum **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/ (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/ Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped, `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 `CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
never touch slot state). 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`) ### 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 This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
+394
View File
@@ -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);
}
}
+290
View File
@@ -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"));
}
}
+266
View File
@@ -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);
}
}
+76
View File
@@ -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);
}
}
+82
View File
@@ -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);
}
}
}
+83
View File
@@ -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);
}
}
+9 -21
View File
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
/// (doesn't fall through and deselect the source) and whether the round clip /// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval. /// renders as a circle rather than an oval.
/// </summary> /// </summary>
[Collection("RealApp")]
public sealed class RoundClipInteractionTests public sealed class RoundClipInteractionTests
{ {
private readonly RealAppHost _app;
public RoundClipInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact] [Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle() public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{ {
Exception? failure = null; _app.Run(Run);
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);
} }
private void Run() private void Run()
{ {
var app = new App();
app.InitializeComponent();
// Never let the real MainWindow read/write the user's actual layout DB — // Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources // Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead. // over the real ones. Point it at a throwaway temp DB instead.
+37
View File
@@ -180,6 +180,43 @@ public class SceneCompositorTests
// untouched corner stays pure red // untouched corner stays pure red
AssertColor(output, 0, 0, 255, 0, 0); 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 public class StretchMathTests
+22
View File
@@ -145,6 +145,28 @@ public class ScreenCaptureManagerTests
Assert.False(await manager.AcquireAsync(" ")); 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 // The one integration test for this branch: one target creates one shared
// WriteableBitmap, published once, and back-to-back frames coalesce to the // WriteableBitmap, published once, and back-to-back frames coalesce to the
// latest (a single pending UI copy per session). // latest (a single pending UI copy per session).
+80
View File
@@ -1,8 +1,11 @@
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Xunit; using Xunit;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests; namespace ytLive.Tests;
@@ -16,6 +19,83 @@ public class SocialBarTests
return path; 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] [Fact]
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings() public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
{ {
+33
View File
@@ -103,6 +103,39 @@ public class SocialValidatorTests
Assert.Null(result.FediverseSoftware); 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] [Fact]
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled() public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
{ {
@@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests
{ {
public int LookupCount { get; private set; } 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) public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{ {
LookupCount++; LookupCount++;
@@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> Gate.Task; => Gate.Task;
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>(null);
} }
private sealed class SignInFake private sealed class SignInFake
+65
View File
@@ -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 */ }
}
}
}
+1
View File
@@ -36,6 +36,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/> <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="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
</ItemGroup> </ItemGroup>