Compare commits

...

8 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
39 changed files with 2621 additions and 359 deletions
+70 -44
View File
@@ -5,52 +5,78 @@
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
> end, compaction, or any interruption.
## Session state (last updated: 2026-08-12)
## Session state (last updated: 2026-08-13)
- **Branch:** `main`. TASK 4 ship step 4 (WASAPI audio capture) is **built and
tested** but **NOT committed** — the working tree is dirty with the audio
layer (6 new files in `Services/Audio/` + `ytLive.Tests/AudioMixerTests.cs`,
the `NAudio.Wasapi` 2.2.1 package reference, the THIRD-PARTY-NOTICES entry,
the `MainViewModel` wiring, the `FfmpegEncoder.cs:139` CS8602 fix) and its
memory updates (TASKS.md, ai.md, Services/index.md, HANDOFF.md). Ready to
commit when the user says so.
- **Finished this session:** TASK 4 ship step 4 — the live audio capture layer:
`IAudioSource`/`AudioSample` seam, `WasapiLoopbackAudioSource`
(`WasapiLoopbackCapture` on the default render device), `WasapiMicAudioSource`
(`WasapiCapture`, NAudio device resolved by `FriendlyName` matching
`MicSourceName` via a re-read `Func<string?>` provider, default-endpoint
fallback), `AudioMixer` (owns both sources, starts/stops with go-live, feeds
the pure `AudioLevelMeter``MicLevelChanged`), `WaveToFloat` (IEEE-float /
PCM16 / extensible). `MainViewModel` constructs the mixer, `BeginGoLive`
success → `Start()`, `StopStream``Stop()`, `MicLevelChanged` marshalled to
the UI thread → `AudioLevel`. Loopback samples are currently dropped (the
encoder's AAC mix, ship step 5, consumes them). Build: **0 warnings** (a
pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during the rebuild and
was fixed with `process!`). Tests: **139 passing** (was 122; +17 new audio).
- **Branch:** `main`, in sync with `origin/main`.
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
persisted and still dims a hidden row to 45%. Removed dead surface:
`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
`BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
`EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
the eye style now binds `IsVisible` and hidden rows dim to 45%.
3. **Duplicate naming:** shared `NextSourceName(scene, baseName)``Image`, `Image2`,
`Image3` (no space), next free number derived from actual names so deletions never
collide. Used by both `AddSource` and `AddReusedImage`.
4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
`SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
on resize; the preview absorbs it. No change.
6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
throttling when unfocused + GPU readback contention + the `_framePending` /
`DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
that calls `new App()` directly again — marshal onto `RealAppHost` instead.
- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
(commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (`IsEditing`),
`Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
(new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
(now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
- **Landmines:**
- `WaveFormatExtensible.SubFormat` is a **`Guid`** in NAudio 2.x, not a
`WaveFormatEncoding` — compare it to
`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT` (the IEEE-float
subtype constant; it lives in the `NAudio.Dmo` namespace, not CoreAudioApi).
- Use the **`NAudio.Wasapi` 2.2.1 feature package**, not the `NAudio`
meta-package — the wasapi types come from that package (`WasapiCapture` in
`NAudio.CoreAudioApi`, `WasapiLoopbackCapture` in `NAudio.Wave`); `NAudio.Core`
comes in transitively.
- The audio meter is an exponential smoother (0.2 factor) — a single pushed
sample only moves 20% toward its RMS. Tests must push repeatedly before
asserting a converged level.
- Tests never instantiate `MainViewModel` directly except the round-clip
integration test (a real `MainWindow`), which never goes live — so the mixer
is constructed but never started there; NAudio types are only constructed,
never touching devices. Keep it that way.
- Mic device resolution must re-read `MicSourceName` at each `Start` (the app
persists only the DisplayName, not a device ID).
- Windows-only: all capture runs only while live (privacy indicator otherwise).
- **Next step:** TASK 4 ship step 5 — the frame-pipeline wiring
(`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder
construction, plus the loopback → encoder AAC mix). Nothing else queued — do
not expand the task queue on your own.
- Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
`OnLoopbackSample` compute the level first, then raise the event — a
`?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
- `MicConnected` comes from the source `Started` event, raised right after
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
before asserting connection state.
- The mic dot is red until a resource connects (see contract below) — green
only after `Started`, yellow on `Failed`.
- Zero mic devices at startup = red dot AND the mixer is never started, so
loopback + the game bar can't run either (no capture at all) — acceptable.
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
`OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
- The pump reads the active scene on a background thread while the UI can still
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
the pump stops), not a crash.
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
order deadlocks.
- Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
on the `RealAppHost` STA thread (round-clip + naming), which never go live.
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
only, never the real instance.
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
devices at startup → stays red and the mixer is never started; capture failure → yellow.
- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4
requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the
dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's
private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice
filters). Nothing else queued — do not expand the task queue on your own. Optional, not
queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
(user must say the word).
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
+166 -94
View File
@@ -49,21 +49,21 @@
</DrawingImage.Drawing>
</DrawingImage>
<!-- Eye icon: open when visible, slashed when hidden -->
<!-- Eye icon: open when visible, slashed when hidden (source rows) -->
<Style x:Key="EyeIconStyle" TargetType="Path">
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True">
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="ToolTip" Value="Hide Scene"/>
<Setter Property="ToolTip" Value="Hide Source"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True">
<Setter Property="ToolTip" Value="Show Scene"/>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="ToolTip" Value="Show Source"/>
</DataTrigger>
</Style.Triggers>
</Style>
@@ -86,27 +86,32 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand -->
<TextBlock Grid.Column="0" Text="ytLlive"
Foreground="#e94560" FontSize="22" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,24,0"/>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
</Button>
<!-- LIVE badge (center) -->
<!-- REC sign (center): dark offline, red live, darker red when private -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center"
Opacity="{Binding LivePulseOpacity}"/>
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold"
VerticalAlignment="Center" Margin="8,0,0,0"/>
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"/>
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="About" Style="{StaticResource YtButtonSecondary}"
Click="AboutButton_Click" Margin="0,0,12,0" VerticalAlignment="Center"/>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}"
@@ -188,64 +193,8 @@
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SceneNameBox_IsVisibleChanged"
KeyDown="SceneNameBox_KeyDown" LostFocus="SceneNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Scene Name"
Command="{Binding DataContext.EditSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleSceneVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Delete Scene"
Command="{Binding DataContext.RemoveSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -282,6 +231,11 @@
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
@@ -290,6 +244,8 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.ContextMenu>
@@ -305,9 +261,51 @@
</Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/>
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Button Grid.Column="1" ToolTip="Remove Source"
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Source Name"
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Remove Source"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
@@ -654,13 +652,12 @@
<!-- Social bar: one centered horizontal row at the top or bottom of
the frame, green glow while enabled. Global resource — never a
Source, no Sources entry. Dragging snaps to the nearest edge. -->
Source, no Sources entry. Click toggles top ⇄ bottom. -->
<Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
Background="Transparent"
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
PreviewMouseLeftButtonDown="SocialBar_PreviewMouseLeftButtonDown"
PreviewMouseMove="SocialBar_PreviewMouseMove"
PreviewMouseLeftButtonUp="SocialBar_PreviewMouseLeftButtonUp">
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
Cursor="Hand">
<Grid.Effect>
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
@@ -680,9 +677,7 @@
Stretch="Uniform" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Handle}" Foreground="White"
FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
MaxWidth="200"/>
Margin="8,0,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -745,6 +740,63 @@
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<!-- Game audio bar: overlaid at the bottom of the preview
window while a full-screen game is producing sound
(the game audio detector). Monitoring UI only — it
lives in the XAML preview and never reaches the live
output. Desktop audio itself is automatic WASAPI
loopback. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12"
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Game Audio Capture" Foreground="#a0a0b0" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Desktop/game audio via WASAPI loopback (automatic)"/>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
@@ -800,9 +852,10 @@
<!-- Line 1: Social controls on the LEFT (under scenes/sources),
sound meter + volume control CENTERED beneath the preview panel.
Audio is KISS: desktop/game audio is automatic ("it just is" —
WASAPI loopback at unity, zero UI); the creator's only audio
control is the mic — meter, volume, mute. -->
Audio is KISS: desktop/game audio is automatic (WASAPI loopback
at unity); the mic meter is the creator's only footer audio
control (the game audio bar lives INSIDE the preview window,
overlaid at its bottom edge while a full-screen game is up). -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
@@ -825,13 +878,19 @@
</Button>
</StackPanel>
<!-- Meter + volume: centered under the preview panel -->
<!-- Mic meter + volume: centered under the preview panel -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
ToolTip="Choose the microphone (voice source)"
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/>
<Button Command="{Binding OpenMicPickerCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="{Binding MicStatusToolTip}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="MIC"/>
</StackPanel>
</Button>
<!-- Sound meter: muted track with a ruler scale, zone tints at
the yellow (60%) and red (80%) starts. READ-ONLY realtime
level display (fill = live level scaled by volume). -->
@@ -863,6 +922,19 @@
VerticalAlignment="Stretch"/>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
+39 -65
View File
@@ -1,7 +1,5 @@
using System.Collections;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -65,7 +63,7 @@ public partial class MainWindow : Window
: null;
}
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
@@ -83,12 +81,6 @@ public partial class MainWindow : Window
}
}
private void MicLabel_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.OpenMicPickerCommand.Execute(null);
}
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -107,6 +99,24 @@ public partial class MainWindow : Window
vm.SetVolumeAdjusting(false);
}
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(true);
}
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -115,6 +125,14 @@ public partial class MainWindow : Window
}
}
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleGameMuteCommand.Execute(null);
}
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
@@ -125,21 +143,6 @@ public partial class MainWindow : Window
}
}
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
// LGPL/BSD/MIT notices ship next to the exe; open in the OS text viewer.
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
if (!File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
AppLog.Write(ex, "About: failed to open THIRD-PARTY-NOTICES.txt");
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
@@ -254,41 +257,12 @@ public partial class MainWindow : Window
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
// ─── Social bar drag: vertical only, snaps to top/bottom on release ───
private const double SocialBarBottomTop = 1040;
private bool _isDraggingSocialBar;
private double _socialBarGrabOffsetY;
private void SocialBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
// unusable for shaky hands (jitter around the direction deadzone), so the bar
// rides on {Binding SocialBarTop} alone and a click flips it. ───
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var bar = (FrameworkElement)sender;
_isDraggingSocialBar = true;
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
var p = toCanvas.Transform(e.GetPosition(bar));
_socialBarGrabOffsetY = p.Y - Canvas.GetTop(bar);
bar.CaptureMouse();
e.Handled = true;
}
private void SocialBar_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_isDraggingSocialBar) return;
var bar = (FrameworkElement)sender;
var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse;
var p = toCanvas.Transform(e.GetPosition(bar));
Canvas.SetTop(bar, Math.Clamp(p.Y - _socialBarGrabOffsetY, 0, SocialBarBottomTop));
e.Handled = true;
}
private void SocialBar_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (!_isDraggingSocialBar) return;
var bar = (FrameworkElement)sender;
var top = Canvas.GetTop(bar);
bar.ReleaseMouseCapture();
_isDraggingSocialBar = false;
var position = top <= SocialBarBottomTop / 2.0 ? SocialBarPosition.Top : SocialBarPosition.Bottom;
_viewModel.SetSocialBarPosition(position);
_viewModel.ToggleSocialBarPosition();
e.Handled = true;
}
@@ -398,24 +372,24 @@ public partial class MainWindow : Window
return null;
}
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSceneEdit(sender);
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSceneEdit(sender);
CommitSourceEdit(sender);
}
private static void CommitSceneEdit(object sender)
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: Scene scene })
scene.IsEditing = false;
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
+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();
private string _name = string.Empty;
private bool _isEditing;
private bool _isHidden;
private bool _hasBackdrop;
private bool _hasSocialBar;
@@ -20,12 +19,6 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _name, value);
}
public bool IsEditing
{
get => _isEditing;
set => Set(ref _isEditing, value);
}
public bool IsHidden
{
get => _isHidden;
+3
View File
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
private bool _isEditing;
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape
{
+17 -3
View File
@@ -19,8 +19,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
public string Handle { get; init; } = string.Empty;
public string ProfileUrl { get; init; } = string.Empty;
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.</summary>
public string? FediverseSoftware { get; init; }
private string? _fediverseSoftware;
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.
/// Settable so the load-time heal can fill a missing name and the bar icon
/// updates in place.</summary>
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (_fediverseSoftware == value) return;
_fediverseSoftware = value;
Raise(nameof(FediverseSoftware));
Raise(nameof(LogoData));
}
}
public string ServiceName => Service.ToString();
public string LogoData => Service == SocialService.Fediverse
@@ -238,7 +252,7 @@ public static class SocialServiceIcons
}
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
private static bool TryParseFediverse(string input, out string user, out string domain)
public static bool TryParseFediverse(string input, out string user, out string domain)
{
user = domain = string.Empty;
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
+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` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain`**Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
+14
View File
@@ -13,6 +13,20 @@ public sealed class AudioLevelMeter
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)
+46 -5
View File
@@ -4,15 +4,18 @@ namespace ytLive.Services.Audio;
/// <summary>
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
/// the live mic meter. Runs only while live: started when go-live succeeds,
/// stopped on end-stream. Mic samples are level-metered and forwarded; loopback
/// samples are currently dropped (consumed by the encoder mix in a later step).
/// 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;
@@ -21,8 +24,10 @@ public sealed class AudioMixer : IDisposable
_mic = mic;
_loopback = loopback;
_meter = new AudioLevelMeter();
_loopbackMeter = new AudioLevelMeter();
_log = log;
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
_loopback.SampleReady += OnLoopbackSample;
_mic.Failed += OnMicFailed;
@@ -32,9 +37,21 @@ public sealed class AudioMixer : IDisposable
/// <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)
@@ -46,6 +63,17 @@ public sealed class AudioMixer : IDisposable
_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)
@@ -55,12 +83,15 @@ public sealed class AudioMixer : IDisposable
_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;
@@ -69,20 +100,30 @@ public sealed class AudioMixer : IDisposable
_loopback.Dispose();
}
private void OnMicStarted()
{
MicConnected?.Invoke();
}
private void OnMicSample(AudioSample sample)
{
MicLevelChanged?.Invoke(_meter.Push(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)
{
// Desktop/game audio: captured for the future encoder mix; no UI yet.
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)
+8 -3
View File
@@ -2,9 +2,10 @@ namespace ytLive.Services.Audio;
/// <summary>
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
/// chunks, runs only while live. The default implementations wrap NAudio's
/// WASAPI capture (mic) / loopback (desktop/game); the mixer and the tests
/// consume this interface, never NAudio directly.
/// 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
{
@@ -14,6 +15,10 @@ public interface IAudioSource : IDisposable
/// <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;
+4 -1
View File
@@ -4,12 +4,14 @@ 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. Starts/stops with go-live only.
/// 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;
@@ -24,6 +26,7 @@ public sealed class WasapiLoopbackAudioSource : IAudioSource
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{
+4 -1
View File
@@ -14,12 +14,14 @@ public sealed class WasapiMicAudioSource : IAudioSource
private WasapiCapture? _capture;
/// <param name="micNameProvider">Returns the current mic DisplayName; read
/// at each Start so a device picked mid-session takes effect next go-live.</param>
/// 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;
@@ -35,6 +37,7 @@ public sealed class WasapiMicAudioSource : IAudioSource
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{
+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 →
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash. Transparent regions read opaque black.
/// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
/// </summary>
public VideoFrame Render(
Scene scene,
Func<SceneElement, VideoFrame?> frameFor,
VideoFrame? flashFrame,
CompositorOptions options)
CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -79,7 +82,10 @@ public sealed class SceneCompositor
}
if (flashFrame != null)
BlitFlash(buffer, cropW, cropH, options, flashFrame);
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
@@ -166,17 +172,20 @@ public sealed class SceneCompositor
}
}
/// <summary>1:1 copy of the master-sized branding flash, cropped to the active source rect.</summary>
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash)
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
private static void BlitOverlay(
byte[] dst, int dstW, int dstH, CompositorOptions options,
VideoFrame overlay, int sx0, int sy0)
{
for (var y = 0; y < dstH; y++)
{
for (var x = 0; x < dstW; x++)
{
var sx = x + options.SourceRectX;
var sy = y + options.SourceRectY;
if (sx >= flash.Width || sy >= flash.Height) continue;
var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy);
var sx = x + options.SourceRectX - sx0;
var sy = y + options.SourceRectY - sy0;
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
}
}
+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);
}
}
+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();
}
}
+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;
}
/// <summary>The most recent frame for a capture key, or null before the first
/// frame arrives (or if the key has no session). The live compositor reads
/// the backdrop from here.</summary>
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
public void Dispose()
{
List<CaptureSession> sessions;
+54 -9
View File
@@ -23,6 +23,11 @@ public sealed class SocialLookupResult
public interface ISocialValidator
{
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
/// <summary>Best-effort nodeinfo software name (mastodon/peertube/...) for a
/// fediverse domain, or null when it can't be resolved. The load-time heal
/// uses this to fix a bar entry whose stored software is missing.</summary>
Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct);
}
/// <summary>
@@ -35,14 +40,19 @@ public interface ISocialValidator
public sealed class HttpSocialValidator : ISocialValidator
{
private readonly HttpClient _client;
private readonly Action<string>? _log;
public HttpSocialValidator(HttpClient? client = null)
public HttpSocialValidator(HttpClient? client = null, Action<string>? log = null)
{
_client = client ?? new HttpClient();
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; ytLlive social validator)");
_log = log;
}
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> TryFetchFediverseSoftwareAsync(domain, ct);
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{
var input = handleOrUrl.Trim();
@@ -127,32 +137,67 @@ public sealed class HttpSocialValidator : ISocialValidator
/// nodeinfo link, read `software.name`. If the identity domain is itself a
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
/// the bare root 302s to the real instance — follow it and ask that host.
/// Failures return null — validation still succeeds, the icon just falls
/// back to the generic fediverse glyph.
/// If neither answers, probe well-known subdomains (mastodon.example.com)
/// so a landing-page domain still resolves its real instance. The whole
/// resolution is bounded by a ~15s budget. Failures return null — validation
/// still succeeds, the icon just falls back to the generic fediverse glyph.
/// </summary>
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
{
try
{
var software = await FetchSoftwareNameAsync(domain, ct);
using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct);
budget.CancelAfter(TimeSpan.FromSeconds(15));
var software = await FetchSoftwareNameAsync(domain, budget.Token);
if (software != null) return software;
var resolved = await ResolveInstanceHostAsync(domain, ct);
if (resolved == null
|| string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
if (resolved != null
&& !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
{
software = await FetchSoftwareNameAsync(resolved, budget.Token);
if (software != null) return software;
}
foreach (var sub in FediverseSubdomainCandidates)
{
var host = $"{sub}.{domain}";
software = await FetchSoftwareNameAsync(host, budget.Token);
if (software != null)
{
_log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}");
return software;
}
}
_log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'");
return null;
return await FetchSoftwareNameAsync(resolved, ct);
}
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
{
return null; // caller checks ct.IsCancellationRequested and reports Canceled
}
catch
catch (System.OperationCanceledException)
{
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out");
return null;
}
catch (System.Exception ex)
{
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}");
return null;
}
}
/// <summary>Common instance subdomains, probed in order when the identity
/// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front
/// of a self-hosted instance).</summary>
private static readonly string[] FediverseSubdomainCandidates =
{
"mastodon", "social", "fediverse", "tube", "peertube",
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
};
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
{
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
+14 -9
View File
@@ -27,28 +27,33 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo``software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host instead. Constructor takes optional `HttpClient` for tests |
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo``software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Not yet constructed by the app (ship step 5 wires it) |
| `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/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) |
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`; runs only while live. The app consumes this seam; tests inject hermetic fakes |
| `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, zero UI |
| `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 mic picked mid-session takes effect next go-live |
| `Audio/AudioMixer.cs` | Owns both sources; `Start`/`Stop` follow go-live (`MainViewModel.BeginGoLive`/`StopStream`). Mic samples → `AudioLevelMeter``MicLevelChanged`; loopback samples currently dropped (the future encoder AAC mix consumes them). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` |
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter``MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead |
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+127 -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…"
10. ✅ **Webcam resource validation + first-frame proof**`MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged``SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule)
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop**`ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo``software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
@@ -115,6 +115,8 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
18. ☐ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
21. ☐ **Logo + richer in-app About (2026-08-13, queued)** — the ytLlive wordmark in the top bar is clickable and opens the About overlay; make a real **logo** (CC0 per the icon guardrail), and expand the About overlay into the creator's hub: **links to premium unlock + coffee/thanks buys + socials** and a second (or third) **scrolling panel with the full licensing/legal texts on demand** — everything stays IN-APP (no Notepad, no browser tab; the app is standalone). The top-bar About button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer was removed on 2026-08-13 for exactly this reason
22. ☐ **Voice filters on the mic channel (2026-08-13, queued — KISS, always on)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Always-on — no UI knobs; the mic stays the creator's single audio control
### The Minimal Source Set (design decision — do not expand casually)
@@ -201,8 +203,8 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
5. **Frame-pipeline wiring**`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder
6. **Health stats** bitrate, FPS, dropped frames reported live in the bottom bar (req 5)
5. **Frame-pipeline wiring SHIPPED** (2026-08-12)`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
6. **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
@@ -336,8 +338,8 @@ provides one.
1. `IFfmpegLocator.cs` — the seam.
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
output and surfaced via the top-bar **About** button (`MainWindow` code-behind, opens the file in the
OS viewer).
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
@@ -355,8 +357,8 @@ parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health sta
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output and is surfaced by a new
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**.
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
@@ -423,8 +425,104 @@ lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFl
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; ship step 5 owns the encoder construction), WASAPI capture while
not live, and any audio UI beyond the existing mic controls.
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`.
---
@@ -505,6 +603,26 @@ not live, and any audio UI beyond the existing mic controls.
---
## TASK 7 — UI polish batch (scenes/sources rows, dedup naming, social bar)
**Goal:** clean up the two side lists and the social bar per gramps's review.
### Status: ✅ Done
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
### Design decisions
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
---
## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+4 -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
download of these components.
If this file changes, update it here AND in the app's About screen (it opens
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for
the guardrails — the "never do" list is there on purpose.
If this file changes, update it here AND in the app's About screen (the About
overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
ai.md ("Licensing — do not violate") for the guardrails — the "never do" list is
there on purpose.
--------------------------------------------------------------------------------
+524 -52
View File
@@ -15,6 +15,8 @@ using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
@@ -25,6 +27,8 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _gameAudioTimer;
private Scene? _activeScene;
private SceneElement? _selectedElement;
@@ -39,6 +43,14 @@ public class MainViewModel : ViewModelBase
private bool _micMuted;
private double? _volumeBeforeMute;
private string? _micSourceName;
private MicStatus _micStatus = MicStatus.NotConnected;
private double _gameAudioLevel;
private bool _gameVolumeAdjusting;
private bool _gameVolumeFlash;
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
private bool _isGameAudioBarVisible;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
@@ -49,7 +61,7 @@ public class MainViewModel : ViewModelBase
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00";
private double _livePulseOpacity = 1.0;
private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed;
private bool _isSettingsOpen;
@@ -78,20 +90,30 @@ public class MainViewModel : ViewModelBase
private Webcam? _webcam;
private string? _webcamError;
private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
// mic via WASAPI capture, both owned by the mixer and running only while
// live. Mic level feeds AudioLevel (the meter); loopback is for the future
// encoder mix. Private by design — no UI beyond the existing mic controls.
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
// lifetime (started at startup, stopped on shutdown) so the footer meters
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
// feeds the game audio bar's meter. Private by design — the mixer surfaces
// the levels + mic connection state to the UI.
private readonly AudioMixer _audioMixer;
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func<string?> _rtmpUrlProvider;
private readonly FramePump _framePump;
// Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly IGameAudioDetector _gameAudioDetector;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage;
@@ -230,7 +252,10 @@ public class MainViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(LiveIndicatorVisible));
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -263,8 +288,9 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _accountDisplayName, value);
}
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
// ─── only audio control is the mic: meter + volume + mute.) ───
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
// ─── visible; the game bar (meter + volume + mute) appears only while a
// ─── full-screen game is producing sound. Both meters preview live. ───
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
@@ -286,7 +312,7 @@ public class MainViewModel : ViewModelBase
/// level flashes on the bar) so the creator sees where they're setting it;
/// otherwise the realtime live level scaled by volume (raising the volume
/// moves ambient noise up the bar). Read-only meter.</summary>
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevel * MicVolume);
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume);
public double MeterFillWidth => MeterLevel * 288;
@@ -400,12 +426,178 @@ public class MainViewModel : ViewModelBase
Owner = System.Windows.Application.Current?.MainWindow
};
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName;
_audioMixer.RestartMic(); // swap the live device immediately
}
}
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// <summary>Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).</summary>
public SolidColorBrush MicStatusBrush => MicStatus switch
{
MicStatus.Connected => BarOnBrush,
MicStatus.Problem => MicProblemBrush,
_ => BarOffBrush,
};
public string MicStatusToolTip => MicStatus switch
{
MicStatus.Connected => "Mic connected — click to change",
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
_ => "No mic connected — click to choose a microphone",
};
// ─── Game audio bar (desktop/game): visible only while a full-screen game
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
// ─── the mic bar. ───
/// <summary>True while the game audio bar should be shown (driven by
/// IGameAudioDetector via the poll timer).</summary>
public bool IsGameAudioBarVisible
{
get => _isGameAudioBarVisible;
private set => SetProperty(ref _isGameAudioBarVisible, value);
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Displayed game meter level: 0 while muted; the volume position
/// while the slider is dragged (or briefly after an unmute flash); otherwise
/// the realtime live level scaled by volume.</summary>
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
public double GameMeterFillWidth => GameMeterLevel * 288;
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public void SetGameVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelGameVolumeFlash();
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void BeginGameVolumeFlash()
{
_gameVolumeFlash = true;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
_gameVolumeFlashTimer.Stop();
_gameVolumeFlashTimer.Start();
}
private void EndGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
if (_gameVolumeFlash)
{
_gameVolumeFlash = false;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void CancelGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
_gameVolumeFlash = false;
}
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.</summary>
public double GameAudioVolume
{
get => _gameVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_gameMuted)
_gameVolumeBeforeMute ??= _gameVolume;
if (SetProperty(ref _gameVolume, clamped))
{
var muted = clamped == 0;
if (_gameMuted != muted)
{
_gameMuted = muted;
OnPropertyChanged(nameof(GameMuted));
OnPropertyChanged(nameof(GameMuteText));
}
if (!muted)
_gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).</summary>
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
private void ToggleGameMute()
{
if (GameMuted)
{
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
BeginGameVolumeFlash();
}
else
{
_gameVolumeBeforeMute = GameAudioVolume;
GameAudioVolume = 0;
}
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
@@ -486,7 +678,15 @@ public class MainViewModel : ViewModelBase
public string StreamVisibility
{
get => _streamVisibility;
set => SetProperty(ref _streamVisibility, value);
set
{
if (SetProperty(ref _streamVisibility, value))
{
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(IsLivePrivate));
}
}
}
public string WindowTitle
@@ -519,12 +719,6 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _liveElapsedText, value);
}
public double LivePulseOpacity
{
get => _livePulseOpacity;
set => SetProperty(ref _livePulseOpacity, value);
}
// Overlay panels
public bool IsSettingsOpen
{
@@ -737,19 +931,19 @@ public class MainViewModel : ViewModelBase
// Commands
public ICommand AddSceneCommand { get; }
public ICommand EditSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
public ICommand ToggleSceneVisibilityCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; }
public ICommand StartStreamCommand { get; }
@@ -786,6 +980,9 @@ public class MainViewModel : ViewModelBase
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
@@ -797,13 +994,12 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
@@ -811,6 +1007,7 @@ public class MainViewModel : ViewModelBase
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
@@ -844,10 +1041,22 @@ public class MainViewModel : ViewModelBase
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message));
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_gameAudioDetector = new GameAudioDetector(
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
() => (float)GameAudioLevel);
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_gameAudioTimer.Tick += OnGameAudioPollTick;
_gameAudioTimer.Start();
_screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager(
@@ -857,6 +1066,18 @@ public class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => ActiveScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
@@ -948,6 +1169,8 @@ public class MainViewModel : ViewModelBase
ReacquireWebcam();
ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
@@ -1169,6 +1392,9 @@ public class MainViewModel : ViewModelBase
{
_saveDebounce?.Stop();
SaveLayoutNow();
_gameAudioTimer.Stop();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_layoutStore.Dispose();
@@ -1296,26 +1522,16 @@ public class MainViewModel : ViewModelBase
ActiveScene = scene;
}
private void BeginEditScene(Scene? scene)
private void BeginEditElement(SceneElement? element)
{
if (scene == null) return;
scene.IsEditing = true;
if (element == null) return;
element.IsEditing = true;
}
private void ToggleSceneVisibility(Scene? scene)
private void ToggleElementVisibility(SceneElement? element)
{
if (scene == null) return;
scene.IsHidden = !scene.IsHidden;
if (scene.IsHidden && ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
}
private void RemoveScene(Scene? scene)
{
if (scene == null) return;
Scenes.Remove(scene);
if (ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault();
if (element == null) return;
element.IsVisible = !element.IsVisible;
}
private void AddSource(object? parameter)
@@ -1358,15 +1574,30 @@ public class MainViewModel : ViewModelBase
return;
}
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
scene.Elements.Add(new Source { Name = name, Type = sourceType });
scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
private static string NextSourceName(Scene scene, string baseName)
{
var taken = scene.Elements.OfType<Source>()
.Select(s => s.Name)
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|| (n.Length > baseName.Length
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(n.Substring(baseName.Length), out _)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!taken.Contains(baseName)) return baseName;
for (var i = 2; ; i++)
if (!taken.Contains($"{baseName}{i}"))
return $"{baseName}{i}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
@@ -1581,10 +1812,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
@@ -1738,7 +1966,8 @@ public class MainViewModel : ViewModelBase
? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming;
_audioMixer.Start();
ResetHealth(StreamStatus.Streaming);
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
}
}
@@ -1746,7 +1975,10 @@ public class MainViewModel : ViewModelBase
{
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive";
_audioMixer.Stop();
ResetHealth(StreamStatus.Offline);
// Audio capture is always-on (preview monitoring); only the frame pump
// and the session stop here.
_ = _framePump.StopAsync();
// Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash
// never runs this, so the token survives and the creator stays signed in.
@@ -1768,6 +2000,159 @@ public class MainViewModel : ViewModelBase
AudioLevel = level;
}
private void OnLoopbackLevelChanged(float level)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => GameAudioLevel = level);
else
GameAudioLevel = level;
}
private void OnMicConnected()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
else
MicStatus = MicStatus.Connected;
}
private void OnMicFailed(Exception ex)
{
// The mixer logs the failure detail; here we only flip the dot to yellow.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
else
MicStatus = MicStatus.Problem;
}
/// <summary>Starts capture once at startup: with no mic device present the
/// dot stays red and capture never starts; otherwise the mixer starts and
/// raises MicConnected (green) or MicFailed (yellow).</summary>
private async Task StartMicCaptureAsync()
{
try
{
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
if (mics.Count == 0)
{
MicStatus = MicStatus.NotConnected;
AppLog.Write("Mic: no capture devices found — mic capture not started");
return;
}
_audioMixer.Start();
}
catch (Exception ex)
{
AppLog.Write($"Mic: device check failed: {ex.Message}");
}
}
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
private void OnGameAudioPollTick(object? sender, EventArgs e)
{
try
{
_gameAudioDetector.Poll();
}
catch (Exception ex)
{
AppLog.Write($"Game audio detection failed: {ex.Message}");
}
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
{
return element switch
{
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
SourceRectY = (int)Math.Round(OutputRectY),
SourceRectWidth = (int)Math.Round(OutputRectWidth),
SourceRectHeight = (int)Math.Round(OutputRectHeight),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
if (string.IsNullOrWhiteSpace(url)) return null;
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url,
Width = quality.Width,
Height = quality.Height,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
private void OnFramePumpFailed(object? sender, string message)
{
AppLog.Write($"Frame pump failed: {message}");
if (IsLive) StreamStatus = StreamStatus.Error;
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals()
{
var live = IsLive;
@@ -1779,7 +2164,8 @@ public class MainViewModel : ViewModelBase
{
_liveElapsed = TimeSpan.Zero;
LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0;
_recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
_liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer();
}
@@ -1790,7 +2176,8 @@ public class MainViewModel : ViewModelBase
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0;
_recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
}
@@ -1823,11 +2210,89 @@ public class MainViewModel : ViewModelBase
{
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
// ─── Social bar ───
/// <summary>
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
/// fediverse entry that doesn't have one, so the bar shows the instance's real
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
/// leaves the glyph untouched. Returns handle → software for what was resolved —
/// the caller applies + persists (the app hops to the dispatcher; a test applies
/// directly).
/// </summary>
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
SocialsConfig socials,
ISocialValidator validator,
Action<string>? log = null)
{
var resolved = new Dictionary<string, string>();
if (socials == null || validator == null) return resolved;
foreach (var entry in socials.Entries)
{
if (entry.Service != SocialService.Fediverse
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
continue;
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
continue;
var software = await validator.ResolveFediverseSoftwareAsync(
domain, System.Threading.CancellationToken.None);
if (string.IsNullOrWhiteSpace(software)) continue;
resolved[entry.Handle] = software;
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
}
return resolved;
}
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
private void HealFediverseSoftwareInBackground()
{
var socials = _socials;
if (socials == null) return;
_ = Task.Run(async () =>
{
try
{
var resolved = await HealFediverseSoftwareAsync(
socials, _socialValidator, m => AppLog.Write(m));
if (resolved.Count == 0) return;
await Application.Current.Dispatcher.InvokeAsync(() =>
{
var current = _socials;
if (current == null) return;
var applied = false;
foreach (var entry in current.Entries)
{
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
if (entry.FediverseSoftware == software) continue;
entry.FediverseSoftware = software;
applied = true;
}
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
});
}
catch (Exception ex)
{
AppLog.Write($"Socials heal failed: {ex.Message}");
}
});
}
/// <summary>
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
/// read it from its own thread. Null when the bar is off or empty.
/// </summary>
private void RenderSocialBarFrame()
{
_socialBarFrame = _socials != null && _socials.BarEnabled
? SocialBarRenderer.Render(_socials.Entries)
: null;
}
/// <summary>
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
/// validation). On save the working copy replaces <see cref="_socials"/>.
@@ -1851,13 +2316,14 @@ public class MainViewModel : ViewModelBase
private void NotifySocialsChanged()
{
RenderSocialBarFrame();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave();
}
/// <summary>Drag release snaps the bar to the closest edge; called by the preview drag.</summary>
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
public void SetSocialBarPosition(SocialBarPosition position)
{
if (_socials == null || _socials.BarPosition == position) return;
@@ -1866,6 +2332,12 @@ public class MainViewModel : ViewModelBase
ScheduleSave();
}
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
public void ToggleSocialBarPosition()
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
? SocialBarPosition.Bottom
: SocialBarPosition.Top);
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
private async Task SignOutYouTubeAsync()
{
+1 -1
View File
File diff suppressed because one or more lines are too long
+111 -22
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** |
| `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)**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` (see "Live audio capture")** |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
@@ -100,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
- `ViewModelBase.SetProperty<T>()` for property change notifications
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevel * MicVolume)` (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)``ToDisplay` maps the raw linear RMS onto a 60..0 dBFS display scale, because real speech sits around 40..20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp``ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
@@ -180,6 +180,14 @@ instead of a normal draggable source.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
game are behind the picker; picker-based captures don't survive reload.
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
@@ -352,41 +360,102 @@ integration test fakes the whole subprocess (probe + encoder) with a Channel-bac
**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. Not yet constructed by the app — the frame-pipeline wiring (ship step 5) owns it.
`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, plan in TASKS.md)
### 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 only while live and is KISS by rule**: desktop/game audio is automatic (WASAPI loopback,
zero UI), 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`/`SampleReady`/`Failed`,
IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no devices, no
timers).
**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 next go-live.
- **`AudioMixer`** owns both sources; `Start`/`Stop` follow go-live (`MainViewModel.BeginGoLive` success
`_audioMixer.Start()`, `StopStream``Stop()`). Mic samples feed a pure **`AudioLevelMeter`** (RMS
with 0.2 exponential smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`.
Desktop samples are currently **dropped** — the future encoder's AAC mix (ship step 5) consumes them,
replacing the `-f lavfi -i anullsrc` placeholder. Failures log via `AppLog`; a mic failure zeroes the
meter, a loopback failure never kills the 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.
- `FfmpegEncoder.cs:139` pre-existing CS8602 fixed (`process!`) — build **0 warnings**; **139 passing**.
- Build **0 warnings**; **169 passing** (mixer/hysteresis/game-detector/meter-scale unit tests).
**Deferred:** loopback→encoder mix wiring and the encoder construction (ship step 5); capture while not
live is deliberately not shipped (privacy indicator otherwise).
**Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame
pipeline shipped in ship step 5).
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md)
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active
scene each tick, resolves every element to its latest frame, composites it into the tier's output frame,
and paces frames into the encoder at the tier's FPS. **Pattern — everything is a constructor-injected
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, and an injectable pacing delay
(default `Task.Delay`; tests inject `Task.Yield`). The pump is free of WPF and of the capture managers.
- **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log
+ surface via the `Failed` event. `EncoderOptions == null` means "no RTMP URL": the pump logs and skips
the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func<string?>` returning null
until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow.
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
`StreamStatus.Error` (minimal).
- **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar —
`MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig`
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }`
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/
background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*`
doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`,
ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one.
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
(WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread,
the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render`
blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight barHeight` =
bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame`
on load/save/`NotifySocialsChanged`).
- Known consideration: the pump reads the active scene on a background thread while the UI can still edit
it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than
crashing. The background thread + video pipeline is the new reality since this step.
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, top/bottom
snap-drag (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped,
@@ -408,6 +477,26 @@ honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodein
`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
never touch slot state).
**Bar bug-fix branch (2026-08-13) — three changes:**
1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that
permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the
bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on
release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't
let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom
(`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone.
2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because
nodeinfo was only ever asked of the identity domain (a landing page; the real instance is
`mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes
well-known subdomains** (`mastodon.``social.` → … `FediverseSubdomainCandidates`) when the identity
domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the
static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher,
and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place.
3. **The bar renders on the live output**`Compositor/SocialBarRenderer.cs` rasterizes the entries into a
transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in,
Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
pipeline").
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
+114
View File
@@ -18,6 +18,7 @@ public class AudioMixerTests
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;
@@ -25,6 +26,7 @@ public class AudioMixerTests
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);
}
@@ -115,6 +117,98 @@ public class AudioMixerTests
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()
{
@@ -230,6 +324,26 @@ public class AudioLevelMeterTests
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
+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
/// renders as a circle rather than an oval.
/// </summary>
[Collection("RealApp")]
public sealed class RoundClipInteractionTests
{
private readonly RealAppHost _app;
public RoundClipInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Run();
}
catch (Exception ex)
{
failure = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure != null)
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
_app.Run(Run);
}
private void Run()
{
var app = new App();
app.InitializeComponent();
// Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead.
+37
View File
@@ -180,6 +180,43 @@ public class SceneCompositorTests
// untouched corner stays pure red
AssertColor(output, 0, 0, 255, 0, 0);
}
[Fact]
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
{
var red = Solid(1920, 1080, 255, 0, 0);
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var compositor = new SceneCompositor();
// master-sized overlay: opaque green at (0,0), 50%-white at the center
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
var g = 0;
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
var w = (540 * 1920 + 960) * 4;
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
// flash: opaque magenta at (0,0) — the bar must cover it
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
}
}
public class StretchMathTests
+22
View File
@@ -145,6 +145,28 @@ public class ScreenCaptureManagerTests
Assert.False(await manager.AcquireAsync(" "));
}
[Fact]
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
{
FakeScreenSource? captured = null;
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key));
Assert.Null(manager.GetLatestFrame("monitor:0"));
Assert.True(await manager.AcquireAsync("monitor:0"));
Assert.Null(manager.GetLatestFrame("monitor:0")); // nothing pumped yet
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
captured!.Pump(first);
captured.Pump(second);
Assert.Same(second, manager.GetLatestFrame("monitor:0"));
await manager.ReleaseAsync("monitor:0");
Assert.Null(manager.GetLatestFrame("monitor:0"));
}
// The one integration test for this branch: one target creates one shared
// WriteableBitmap, published once, and back-to-back frames coalesce to the
// latest (a single pending UI copy per session).
+80
View File
@@ -1,8 +1,11 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
@@ -16,6 +19,83 @@ public class SocialBarTests
return path;
}
/// <summary>Resolves every fediverse domain as Mastodon — the heal test only
/// cares that a missing stored software name gets filled in and persisted.</summary>
private sealed class MastodonValidator : ISocialValidator
{
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>("mastodon");
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> throw new System.NotSupportedException();
}
[Fact]
public void SocialEntry_FediverseSoftware_SettableUpdatesLogo()
{
var entry = new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
};
var honeycomb = entry.LogoData;
entry.FediverseSoftware = "mastodon";
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), entry.LogoData);
Assert.NotEqual(honeycomb, entry.LogoData);
}
/// <summary>The single integration test for this branch: a fediverse entry
/// persisted with a NULL software name is loaded, healed via the real validator
/// seam, and the resolved name is persisted back — surviving a second load.</summary>
[Fact]
public async Task Socials_HealMissingFediverseSoftware_RoundTripsThroughDb()
{
var path = TempDbPath();
try
{
var socials = new SocialsConfig();
socials.Entries.Add(new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
});
using (var store = new LayoutStore(path))
store.Save(new[] { new Scene { Name = "Live" } }, null, socials);
SocialsConfig? loaded;
using (var store = new LayoutStore(path))
{
store.Load();
loaded = store.Socials;
Assert.NotNull(loaded);
Assert.Null(loaded!.Entries[0].FediverseSoftware);
}
var healed = await MainViewModel.HealFediverseSoftwareAsync(loaded!, new MastodonValidator());
Assert.Single(healed);
Assert.Equal("mastodon", healed["@gramps@llamachile.tube"]);
loaded.Entries[0].FediverseSoftware = healed["@gramps@llamachile.tube"];
using (var store = new LayoutStore(path))
store.Save(new[] { new Scene { Name = "Live" } }, null, loaded);
using (var store = new LayoutStore(path))
{
store.Load();
var again = store.Socials;
Assert.NotNull(again);
Assert.Equal("mastodon", again!.Entries[0].FediverseSoftware);
}
}
finally
{
SqliteConnection.ClearAllPools();
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
{
+33
View File
@@ -103,6 +103,39 @@ public class SocialValidatorTests
Assert.Null(result.FediverseSoftware);
}
[Fact]
public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains()
{
// The identity domain is a silent landing page (no nodeinfo, no redirect);
// the real instance lives on mastodon.<domain> — the probe must find it.
var validator = Validator(new StubHandler(request =>
{
var host = request.RequestUri!.Host;
if (host == "mastodon.llamachile.tube")
{
if (request.RequestUri.AbsolutePath.StartsWith("/.well-known"))
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } });
if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo"))
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
}));
var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None);
Assert.Equal("mastodon", software);
}
[Fact]
public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None);
Assert.Null(software);
}
[Fact]
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
{
@@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests
{
public int LookupCount { get; private set; }
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>("mastodon");
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{
LookupCount++;
@@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> Gate.Task;
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>(null);
}
private sealed class SignInFake
+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 */ }
}
}
}