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