Compare commits
24 Commits
b00a4cbd5e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d90b5ded0d | |||
| 8292663791 | |||
| dc29adf9bb | |||
| 18abe4210f | |||
| 9f9ed34627 | |||
| ea250c02a6 | |||
| ac60a26d82 | |||
| e72ba71165 | |||
| 58c0f8e8c4 | |||
| ba427e85e1 | |||
| e494dce311 | |||
| b094755e08 | |||
| 7c456042a6 | |||
| 9873eeda7d | |||
| db5fc06174 | |||
| 8868f98392 | |||
| 5437c776fe | |||
| 5e8b8e5835 | |||
| e1d9e1388d | |||
| 3f7f1880c5 | |||
| 60259c08c2 | |||
| cc3b1c9234 | |||
| 8c7938aca0 | |||
| 18a21010bb |
@@ -30,14 +30,27 @@ conventions live here and in `ai.md`.
|
||||
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
|
||||
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
|
||||
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
|
||||
4. `<dir>/index.md` — the index for any directory you're about to touch.
|
||||
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
|
||||
5. `<dir>/index.md` — the index for any directory you're about to touch.
|
||||
|
||||
**If `HANDOFF.md` exists, trust it as current state** — no `fsck`, no branch
|
||||
hunting, no file-scanning to re-derive what it already states, unless it points
|
||||
at a problem.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Never work without the map.** If the map contradicts the code, the code wins
|
||||
and the map gets fixed in the same change (stale facts are corrected, not appended).
|
||||
- **Every feature change ships with its memory update:** `ai.md` for
|
||||
architecture/patterns, `TASKS.md` for status, index files when layout changes.
|
||||
- **Every feature change ships with its memory update in the SAME commit:**
|
||||
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
|
||||
layout changes. No code commit without its docs — a follow-up "docs backfill"
|
||||
commit is a broken rule, not a style.
|
||||
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
|
||||
Never end a session with uncommitted work unrecorded — the handoff names the
|
||||
branch, the dirty files, and why it stopped.
|
||||
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery
|
||||
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
|
||||
re-hunts it.
|
||||
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
|
||||
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
|
||||
(merged once in `App.xaml`; never duplicate per-window).
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# HANDOFF — session state
|
||||
|
||||
> Current operational state, read right after `TASKS.md`. Trust this file as the
|
||||
> truth of what is in flight — do not re-derive from git/fs unless it points at
|
||||
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
|
||||
> end, compaction, or any interruption.
|
||||
|
||||
## Session state (last updated: 2026-08-13)
|
||||
|
||||
- **Branch:** `main`, in sync with `origin/main`.
|
||||
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
|
||||
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
|
||||
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
|
||||
persisted and still dims a hidden row to 45%. Removed dead surface:
|
||||
`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
|
||||
`BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
|
||||
2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
|
||||
`EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
|
||||
lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
|
||||
the eye style now binds `IsVisible` and hidden rows dim to 45%.
|
||||
3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`,
|
||||
`Image3`… (no space), next free number derived from actual names so deletions never
|
||||
collide. Used by both `AddSource` and `AddReusedImage`.
|
||||
4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
|
||||
`SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
|
||||
5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
|
||||
on resize; the preview absorbs it. No change.
|
||||
6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
|
||||
throttling when unfocused + GPU readback contention + the `_framePending` /
|
||||
`DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
|
||||
- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
|
||||
share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
|
||||
serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
|
||||
that calls `new App()` directly again — marshal onto `RealAppHost` instead.
|
||||
- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
|
||||
(commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (−`IsEditing`),
|
||||
`Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
|
||||
(new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
|
||||
(now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
|
||||
- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
|
||||
- **Landmines:**
|
||||
- Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
|
||||
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
||||
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
||||
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
|
||||
`OnLoopbackSample` compute the level first, then raise the event — a
|
||||
`?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
|
||||
nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
|
||||
- `MicConnected` comes from the source `Started` event, raised right after
|
||||
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
|
||||
before asserting connection state.
|
||||
- The mic dot is red until a resource connects (see contract below) — green
|
||||
only after `Started`, yellow on `Failed`.
|
||||
- Zero mic devices at startup = red dot AND the mixer is never started, so
|
||||
loopback + the game bar can't run either (no capture at all) — acceptable.
|
||||
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
|
||||
`OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
|
||||
- The pump reads the active scene on a background thread while the UI can still
|
||||
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
|
||||
the pump stops), not a crash.
|
||||
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
|
||||
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
|
||||
order deadlocks.
|
||||
- Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
|
||||
on the `RealAppHost` STA thread (round-clip + naming), which never go live.
|
||||
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
|
||||
only, never the real instance.
|
||||
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
|
||||
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
|
||||
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
|
||||
from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
|
||||
devices at startup → stays red and the mixer is never started; capture failure → yellow.
|
||||
- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4
|
||||
requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the
|
||||
dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's
|
||||
private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice
|
||||
filters). Nothing else queued — do not expand the task queue on your own. Optional, not
|
||||
queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
|
||||
(user must say the word).
|
||||
|
||||
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
|
||||
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
|
||||
layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; `SocialEntry.Software`
|
||||
column is a column-presence migration like the others, no version bump); OAuth callback
|
||||
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort diagnostic: when a camera won't start, lists other running
|
||||
/// processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, etc.).
|
||||
/// Windows doesn't expose "which process has this device" via any public API,
|
||||
/// so this is a suspect list, not a verdict — but it's far better than
|
||||
/// "maybe in use by another app" with no idea which one.
|
||||
/// </summary>
|
||||
public static class CameraConflictProbe
|
||||
{
|
||||
private static readonly HashSet<string> KnownCameraApps = new()
|
||||
{
|
||||
"obs64", "obs32", "zoom", "teams", "ms-teams", "discord",
|
||||
"nvidia broadcast", "skype", "webex", "slack",
|
||||
"streamlabs obs", "restream studio", "manycam", "snap camera",
|
||||
"logitech capture", "logitune", "facerig", "animaze",
|
||||
"camera", "vmix", "xsplit broadcaster", "xsplit gamecaster",
|
||||
"droidcam", "ivcam", "epoccam", "camo",
|
||||
"chrome", "msedge", "firefox", "brave",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the friendly names of known camera apps currently running, or an
|
||||
/// empty list if none are found (or the probe itself fails).
|
||||
/// </summary>
|
||||
public static List<string> GetRunningCameraApps()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Process.GetProcesses()
|
||||
.Where(p => KnownCameraApps.Contains(p.ProcessName.ToLowerInvariant()))
|
||||
.Select(p => p.ProcessName)
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
|
||||
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
||||
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
||||
|
||||
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
|
||||
class backs; [`Models/index.md`](../Models/index.md) and
|
||||
|
||||
+230
-88
@@ -49,21 +49,21 @@
|
||||
</DrawingImage.Drawing>
|
||||
</DrawingImage>
|
||||
|
||||
<!-- Eye icon: open when visible, slashed when hidden -->
|
||||
<!-- Eye icon: open when visible, slashed when hidden (source rows) -->
|
||||
<Style x:Key="EyeIconStyle" TargetType="Path">
|
||||
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
|
||||
<Setter Property="ToolTip" Value="Hide Scene"/>
|
||||
<Setter Property="ToolTip" Value="Hide Source"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||
<Setter Property="ToolTip" Value="Show Scene"/>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="ToolTip" Value="Show Source"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
@@ -86,21 +86,28 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Logo / Brand -->
|
||||
<TextBlock Grid.Column="0" Text="ytLlive"
|
||||
Foreground="#e94560" FontSize="22" FontWeight="Bold"
|
||||
VerticalAlignment="Center" Margin="0,0,24,0"/>
|
||||
<!-- Logo / Brand — clicking opens the in-app About overlay -->
|
||||
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
|
||||
Command="{Binding OpenAboutCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
|
||||
</Button>
|
||||
|
||||
<!-- LIVE badge (center) -->
|
||||
<!-- REC sign (center): dark offline, red live, darker red when private -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center"
|
||||
Opacity="{Binding LivePulseOpacity}"/>
|
||||
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
||||
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
|
||||
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"/>
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Single three-state action button -->
|
||||
@@ -186,64 +193,8 @@
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||
Padding="4,2" VerticalContentAlignment="Center"
|
||||
IsVisibleChanged="SceneNameBox_IsVisibleChanged"
|
||||
KeyDown="SceneNameBox_KeyDown" LostFocus="SceneNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Edit Scene Name"
|
||||
Command="{Binding DataContext.EditSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
||||
Command="{Binding DataContext.ToggleSceneVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}">
|
||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||
Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" ToolTip="Delete Scene"
|
||||
Command="{Binding DataContext.RemoveSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -280,6 +231,11 @@
|
||||
<Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
@@ -288,6 +244,8 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.ContextMenu>
|
||||
@@ -303,9 +261,51 @@
|
||||
</Grid.ContextMenu>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis"/>
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Remove Source"
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||
Padding="4,2" VerticalContentAlignment="Center"
|
||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Edit Source Name"
|
||||
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
||||
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}">
|
||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||
Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" ToolTip="Remove Source"
|
||||
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
||||
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
|
||||
@@ -649,6 +649,40 @@
|
||||
FontSize="180" TextAlignment="Center"/>
|
||||
</Viewbox>
|
||||
</Grid>
|
||||
|
||||
<!-- Social bar: one centered horizontal row at the top or bottom of
|
||||
the frame, green glow while enabled. Global resource — never a
|
||||
Source, no Sources entry. Click toggles top ⇄ bottom. -->
|
||||
<Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
|
||||
Background="Transparent"
|
||||
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
|
||||
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
|
||||
Cursor="Hand">
|
||||
<Grid.Effect>
|
||||
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
|
||||
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
|
||||
</Grid.Effect>
|
||||
<ItemsControl ItemsSource="{Binding Socials.Entries}"
|
||||
HorizontalAlignment="Center" Margin="40,8"
|
||||
IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
|
||||
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
|
||||
Stretch="Uniform" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Handle}" Foreground="White"
|
||||
FontSize="14" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
@@ -656,6 +690,12 @@
|
||||
Background="#CC16213e" CornerRadius="4" Padding="8,3" IsHitTestVisible="False">
|
||||
<TextBlock Text="{Binding ResolutionBadgeText}" Foreground="#e0e0e0" FontSize="12"/>
|
||||
</Border>
|
||||
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
|
||||
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
|
||||
Visibility="{Binding WebcamError, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="{Binding WebcamError}" Foreground="White" FontSize="12"
|
||||
MaxWidth="520" TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
|
||||
@@ -700,6 +740,63 @@
|
||||
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Game audio bar: overlaid at the bottom of the preview
|
||||
window while a full-screen game is producing sound
|
||||
(the game audio detector). Monitoring UI only — it
|
||||
lives in the XAML preview and never reaches the live
|
||||
output. Desktop audio itself is automatic WASAPI
|
||||
loopback. -->
|
||||
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Background="#D9000000" CornerRadius="6" Padding="12,8"
|
||||
Margin="0,0,0,12"
|
||||
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Game Audio Capture" Foreground="#a0a0b0" FontSize="11"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="Desktop/game audio via WASAPI loopback (automatic)"/>
|
||||
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
|
||||
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
|
||||
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
|
||||
<Path Fill="#d0d0d0" Stretch="Uniform"
|
||||
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Path Fill="#ef4444" Stretch="Uniform"
|
||||
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||
Data="M21,3 L3,21"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
<Slider Width="130" Minimum="0" Maximum="1"
|
||||
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
|
||||
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -753,16 +850,47 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Line 1: sound meter + volume control, centered beneath the
|
||||
middle (preview) panel. Audio is KISS: desktop/game audio is
|
||||
automatic ("it just is" — WASAPI loopback at unity, zero UI);
|
||||
the creator's only audio control is the mic — meter, volume, mute. -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
|
||||
ToolTip="Choose the microphone (voice source)"
|
||||
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/>
|
||||
<!-- Line 1: Social controls on the LEFT (under scenes/sources),
|
||||
sound meter + volume control CENTERED beneath the preview panel.
|
||||
Audio is KISS: desktop/game audio is automatic (WASAPI loopback
|
||||
at unity); the mic meter is the creator's only footer audio
|
||||
control (the game audio bar lives INSIDE the preview window,
|
||||
overlaid at its bottom edge while a full-screen game is up). -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="300"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Social controls: centered under the scenes/sources listboxes -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
ToolTip="Add or manage your social handles">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Socials"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Mic meter + volume: centered under the preview panel -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button Command="{Binding OpenMicPickerCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="{Binding MicStatusToolTip}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="MIC"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- Sound meter: muted track with a ruler scale, zone tints at
|
||||
the yellow (60%) and red (80%) starts. READ-ONLY realtime
|
||||
level display (fill = live level scaled by volume). -->
|
||||
@@ -794,6 +922,19 @@
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
|
||||
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
|
||||
<Path Fill="#d0d0d0" Stretch="Uniform"
|
||||
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Path Fill="#ef4444" Stretch="Uniform"
|
||||
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||
Data="M21,3 L3,21"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
|
||||
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
|
||||
@@ -812,7 +953,8 @@
|
||||
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Line 2: everything else — stream stats on the left, quality +
|
||||
gear on the right. -->
|
||||
|
||||
+43
-14
@@ -63,7 +63,7 @@ public partial class MainWindow : Window
|
||||
: null;
|
||||
}
|
||||
|
||||
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
@@ -81,12 +81,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void MicLabel_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.OpenMicPickerCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
@@ -105,6 +99,24 @@ public partial class MainWindow : Window
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
@@ -113,6 +125,14 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
@@ -237,6 +257,15 @@ public partial class MainWindow : Window
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_viewModel.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (OpacityChip.IsMouseOver) return;
|
||||
@@ -343,24 +372,24 @@ public partial class MainWindow : Window
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSceneEdit(sender);
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSceneEdit(sender);
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSceneEdit(object sender)
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: Scene scene })
|
||||
scene.IsEditing = false;
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>Footer mic indicator state (the dot beside the MIC button):
|
||||
/// <see cref="Connected"/> = mic capture is running; <see cref="Problem"/> = a
|
||||
/// mic was requested but the connection failed (device in use, unplugged, or
|
||||
/// unavailable); <see cref="NotConnected"/> = no capture is active (e.g. no
|
||||
/// mic device present).</summary>
|
||||
public enum MicStatus
|
||||
{
|
||||
NotConnected,
|
||||
Connected,
|
||||
Problem
|
||||
}
|
||||
+11
-7
@@ -9,9 +9,9 @@ public class Scene : INotifyPropertyChanged
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
private string _name = string.Empty;
|
||||
private bool _isEditing;
|
||||
private bool _isHidden;
|
||||
private bool _hasBackdrop;
|
||||
private bool _hasSocialBar;
|
||||
|
||||
public string Name
|
||||
{
|
||||
@@ -19,12 +19,6 @@ public class Scene : INotifyPropertyChanged
|
||||
set => Set(ref _name, value);
|
||||
}
|
||||
|
||||
public bool IsEditing
|
||||
{
|
||||
get => _isEditing;
|
||||
set => Set(ref _isEditing, value);
|
||||
}
|
||||
|
||||
public bool IsHidden
|
||||
{
|
||||
get => _isHidden;
|
||||
@@ -41,6 +35,16 @@ public class Scene : INotifyPropertyChanged
|
||||
set => Set(ref _hasBackdrop, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the social bar plays on this scene. Toggled by the footer [+]/[-]
|
||||
/// control — the socials themselves are a global resource (see SocialsConfig).
|
||||
/// </summary>
|
||||
public bool HasSocialBar
|
||||
{
|
||||
get => _hasSocialBar;
|
||||
set => Set(ref _hasSocialBar, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scene's rendered elements in z-order (back to front): multi-instance
|
||||
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
||||
|
||||
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
private bool _isVisible = true;
|
||||
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
|
||||
|
||||
private bool _isEditing;
|
||||
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
||||
|
||||
private ClipShape _clipShape = ClipShape.Traditional;
|
||||
public ClipShape ClipShape
|
||||
{
|
||||
@@ -227,7 +230,7 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetBorderColor(out byte r, out byte g, out byte b)
|
||||
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
|
||||
{
|
||||
r = g = b = 0;
|
||||
var hex = BorderColor.Trim().TrimStart('#');
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public enum SocialService
|
||||
{
|
||||
YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick,
|
||||
Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit,
|
||||
WhatsApp, Telegram, Link, Website, Fediverse
|
||||
}
|
||||
|
||||
public enum SocialBarPosition { Top, Bottom }
|
||||
|
||||
public sealed class SocialEntry : INotifyPropertyChanged
|
||||
{
|
||||
public SocialService Service { get; init; }
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
|
||||
private string? _fediverseSoftware;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.
|
||||
/// Settable so the load-time heal can fill a missing name and the bar icon
|
||||
/// updates in place.</summary>
|
||||
public string? FediverseSoftware
|
||||
{
|
||||
get => _fediverseSoftware;
|
||||
set
|
||||
{
|
||||
if (_fediverseSoftware == value) return;
|
||||
_fediverseSoftware = value;
|
||||
Raise(nameof(FediverseSoftware));
|
||||
Raise(nameof(LogoData));
|
||||
}
|
||||
}
|
||||
|
||||
public string ServiceName => Service.ToString();
|
||||
public string LogoData => Service == SocialService.Fediverse
|
||||
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
|
||||
: SocialServiceIcons.LogoDataFor(Service);
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Raise([CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The app-wide social bar config — a global resource (like the webcam) that
|
||||
/// every scene shows when <see cref="BarEnabled"/> is on and entries exist. The
|
||||
/// dialog provides the handles; the app validates each by looking up the social
|
||||
/// page before committing. Freemium: YT + 1 other. Premium: all six slots.
|
||||
/// </summary>
|
||||
public sealed class SocialsConfig : INotifyPropertyChanged
|
||||
{
|
||||
public ObservableCollection<SocialEntry> Entries { get; } = new();
|
||||
|
||||
private bool _barEnabled = true;
|
||||
public bool BarEnabled
|
||||
{
|
||||
get => _barEnabled;
|
||||
set => Set(ref _barEnabled, value);
|
||||
}
|
||||
|
||||
private SocialBarPosition _barPosition = SocialBarPosition.Bottom;
|
||||
public SocialBarPosition BarPosition
|
||||
{
|
||||
get => _barPosition;
|
||||
set => Set(ref _barPosition, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brand logo path data (Simple Icons, CC0) plus the small utility glyphs the
|
||||
/// social dialog uses — lock (Premium gate), do-not (empty/invalid slot) and a
|
||||
/// generic link (bare Link/Website entries). All strings are 24x24 path data
|
||||
/// consumed by a Path.Data binding (the Geometry type converter handles the string).
|
||||
/// </summary>
|
||||
public static class SocialServiceIcons
|
||||
{
|
||||
public const string LockedIconData = "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z";
|
||||
public const string DoNotIconData = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z";
|
||||
public const string LinkIconData = "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z";
|
||||
|
||||
private const string YouTubePath = "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z";
|
||||
private const string TwitchPath = "M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z";
|
||||
private const string XPath = "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z";
|
||||
private const string InstagramPath = "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077";
|
||||
private const string TikTokPath = "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z";
|
||||
private const string FacebookPath = "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z";
|
||||
private const string DiscordPath = "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z";
|
||||
private const string KickPath = "M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z";
|
||||
private const string ThreadsPath = "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z";
|
||||
private const string BlueskyPath = "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026";
|
||||
private const string GitHubPath = "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
|
||||
private const string LinkedInPath = "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z";
|
||||
private const string PinterestPath = "M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z";
|
||||
private const string SnapchatPath = "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z";
|
||||
private const string RedditPath = "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z";
|
||||
private const string WhatsAppPath = "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
|
||||
private const string TelegramPath = "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z";
|
||||
|
||||
// Fediverse instance software logos (Simple Icons, CC0). Resolved at
|
||||
// validate-time from each instance's nodeinfo `software.name`; unknown
|
||||
// software falls back to the generic fediverse glyph below.
|
||||
private const string MastodonPath = "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z";
|
||||
private const string PeerTubePath = "M12 6.545v10.91L20.727 12M3.273 12v12L12 17.455M3.273 0v12L12 6.545";
|
||||
private const string PixelfedPath = "M12 24C5.3726 24 0 18.6274 0 12S5.3726 0 12 0s12 5.3726 12 12-5.3726 12-12 12m-.9526-9.3802h2.2014c2.0738 0 3.7549-1.6366 3.7549-3.6554S15.3226 7.309 13.2488 7.309h-3.1772c-1.1964 0-2.1663.9442-2.1663 2.1089v8.208z";
|
||||
private const string MisskeyPath = "M8.91076 16.8915c-1.03957.0038-1.93213-.6294-2.35267-1.366-.22516-.3217-.66989-.4364-.6761 0v2.0148c0 .8094-.29152 1.5097-.87581 2.1002-.56755.573-1.25977.8595-2.0779.8595-.80014 0-1.49298-.2865-2.07727-.8601C.28408 19.05 0 18.3497 0 17.5403V6.45968c0-.62378.17553-1.18863.52599-1.69455.36657-.52284.83426-.88582 1.4018-1.08769a2.84574 2.84574 0 0 1 1.00049-.17742c.90125 0 1.65239.35421 2.25281 1.06262l2.99713 3.51572c.06699.05016.263.43696.73192.43696.47016 0 .6916-.3868.75796-.43758l2.9717-3.5151c.6178-.70841 1.377-1.06262 2.2782-1.06262.3337 0 .6675.05893 1.0012.17742.5669.20187 1.0259.56422 1.377 1.08769.3665.50592.5501 1.07077.5501 1.69455V17.5403c0 .8094-.2915 1.5097-.8758 2.1002-.5675.573-1.2604.8595-2.0779.8595-.8008 0-1.493-.2865-2.0779-.8601-.5669-.5899-.8504-1.2902-.8504-2.0996v-2.0148c-.0496-.5499-.5303-.2032-.7009 0-.4503.8431-1.31369 1.3616-2.35264 1.366ZM21.447 8.60998c-.7009 0-1.3015-.24449-1.8019-.73348-.4838-.50571-.7257-1.11277-.7257-1.82118s.2419-1.30711.7257-1.79611c.5004-.50571 1.101-.75856 1.8019-.75856.7009 0 1.3017.25285 1.8025.75856.5003.489.7505 1.0877.7505 1.79611 0 .70841-.2502 1.31547-.7505 1.82118-.5008.48899-1.1016.73348-1.8025.73348Zm.0248.50655c.7009 0 1.2935.25285 1.7777.75856.5003.50571.7505 1.11301.7505 1.82181v6.2484c0 .7084-.2502 1.3155-.7505 1.8212-.4838.489-1.0764.7335-1.7777.7335-.7005 0-1.3011-.2445-1.8019-.7335-.5003-.5057-.7505-1.1128-.7505-1.8212v-6.2484c0-.7084.2502-1.3157.7505-1.82181.5004-.50571 1.101-.75856 1.8019-.75856Z";
|
||||
private const string LemmyPath = "M2.9595 4.2228a3.9132 3.9132 0 0 0-.332.019c-.8781.1012-1.67.5699-2.155 1.3862-.475.8-.5922 1.6809-.35 2.4971.2421.8162.8297 1.5575 1.6982 2.1449.0053.0035.0106.0076.0163.0114.746.4498 1.492.7431 2.2877.8994-.02.3318-.0272.6689-.006 1.0181.0634 1.0432.4368 2.0006.996 2.8492l-2.0061.8189a.4163.4163 0 0 0-.2276.2239.416.416 0 0 0 .0879.455.415.415 0 0 0 .2941.1231.4156.4156 0 0 0 .1595-.0312l2.2093-.9035c.408.4859.8695.9315 1.3723 1.318.0196.0151.0407.0264.0603.0423l-1.2918 1.7103a.416.416 0 0 0 .664.501l1.314-1.7385c.7185.4548 1.4782.7927 2.2294 1.0242.3833.7209 1.1379 1.1871 2.0202 1.1871.8907 0 1.6442-.501 2.0242-1.2072.744-.2347 1.4959-.5729 2.2073-1.0262l1.332 1.7606a.4157.4157 0 0 0 .7439-.1936.4165.4165 0 0 0-.0799-.3074l-1.3099-1.7345c.0083-.0075.0178-.0113.0261-.0188.4968-.3803.9549-.8175 1.3622-1.2939l2.155.8794a.4156.4156 0 0 0 .5412-.2276.4151.4151 0 0 0-.2273-.5432l-1.9438-.7928c.577-.8538.9697-1.8183 1.0504-2.8693.0268-.3507.0242-.6914.0079-1.0262.7905-.1572 1.5321-.4502 2.2737-.8974.0053-.0033.011-.0076.0163-.0113.8684-.5874 1.456-1.3287 1.6982-2.145.2421-.8161.125-1.697-.3501-2.497-.4849-.8163-1.2768-1.2852-2.155-1.3863a3.2175 3.2175 0 0 0-.332-.0189c-.7852-.0151-1.6231.229-2.4286.6942-.5926.342-1.1252.867-1.5433 1.4387-1.1699-.6703-2.6923-1.0476-4.5635-1.0785a15.5768 15.5768 0 0 0-.5111 0c-2.085.034-3.7537.43-5.0142 1.1449-.0033-.0038-.0045-.0114-.008-.0152-.4233-.5916-.973-1.1365-1.5835-1.489-.8055-.465-1.6434-.7083-2.4286-.6941Zm.2858.7365c.5568.042 1.1696.2358 1.7787.5875.485.28.9757.7554 1.346 1.2696a5.6875 5.6875 0 0 0-.4969.4085c-.9201.8516-1.4615 1.9597-1.668 3.2335-.6809-.1402-1.3183-.3945-1.984-.7948-.7553-.5128-1.2159-1.1225-1.4004-1.7445-.1851-.624-.1074-1.2712.2776-1.9196.3743-.63.9275-.9534 1.6118-1.0322a2.796 2.796 0 0 1 .5352-.0076Zm17.5094 0a2.797 2.797 0 0 1 .5353.0075c.6842.0786 1.2374.4021 1.6117 1.0322.385.6484.4627 1.2957.2776 1.9196-.1845.622-.645 1.2317-1.4004 1.7445-.6578.3955-1.2881.6472-1.9598.7888-.1942-1.2968-.7375-2.4338-1.666-3.302a5.5639 5.5639 0 0 0-.4709-.3923c.3645-.49.8287-.9428 1.2938-1.2113.6091-.3515 1.2219-.5454 1.7787-.5875ZM12.006 6.0036a14.832 14.832 0 0 1 .487 0c2.3901.0393 4.0848.67 5.1631 1.678 1.1501 1.0754 1.6423 2.6006 1.499 4.467-.1311 1.7079-1.2203 3.2281-2.652 4.324-.694.5313-1.4626.9354-2.2254 1.2294.0031-.0453.014-.0888.014-.1349.0029-1.1964-.9313-2.2133-2.2918-2.2133-1.3606 0-2.3222 1.0154-2.2918 2.2213.0013.0507.014.0972.0181.1471-.781-.2933-1.5696-.7013-2.2777-1.2456-1.4239-1.0945-2.4997-2.6129-2.6037-4.322-.1129-1.8567.3778-3.3382 1.5212-4.3965C7.5094 6.7 9.352 6.047 12.006 6.0036Zm-3.6419 6.8291c-.6053 0-1.0966.4903-1.0966 1.0966 0 .6063.4913 1.0986 1.0966 1.0986s1.0966-.4923 1.0966-1.0986c0-.6063-.4913-1.0966-1.0966-1.0966zm7.2819.0113c-.5998 0-1.0866.4859-1.0866 1.0866s.4868 1.0885 1.0866 1.0885c.5997 0 1.0865-.4878 1.0865-1.0885s-.4868-1.0866-1.0865-1.0866zM12 16.0835c1.0237 0 1.5654.638 1.5634 1.4829-.0018.7849-.6723 1.485-1.5634 1.485-.9167 0-1.54-.5629-1.5634-1.493-.0212-.8347.5397-1.4749 1.5634-1.4749Z";
|
||||
private const string PleromaPath = "M6.36 0A1.868 1.868 0 004.49 1.868V24h5.964V0zm7.113 0v12h4.168a1.868 1.868 0 001.868-1.868V0zm0 18.036V24h4.168a1.868 1.868 0 001.868-1.868v-4.096Z";
|
||||
private const string FirefishPath = "M16.771 0c-.68-.016-1.342.507-1.342 1.304V7.27c0 .719.582 1.301 1.3 1.301h5.967c1.16 0 1.74-1.401.92-2.22L17.65.383a1.275 1.275 0 0 0-.879-.383ZM6.573.106c-.672-.017-1.326.5-1.326 1.287v5.892c0 .71.575 1.285 1.285 1.285h5.892c1.145 0 1.718-1.384.908-2.194L7.44.484a1.259 1.259 0 0 0-.867-.379ZM1.286 10.287c-.71 0-1.286.576-1.286 1.286v11.142C0 23.425.576 24 1.286 24h11.143c.71 0 1.285-.575 1.285-1.285V11.573c0-.71-.575-1.286-1.285-1.286zm15.485 0c-.68-.017-1.342.507-1.342 1.304v5.966c0 .718.582 1.3 1.3 1.3h5.967c1.16 0 1.74-1.4.92-2.22L17.65 10.67a1.275 1.275 0 0 0-.879-.384zM3.43 17.144a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428zm4.285 0a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428z";
|
||||
|
||||
/// <summary>Generic fediverse glyph (honeycomb) for instances whose software isn't in our icon set.</summary>
|
||||
public const string FediverseIconData = "M12 4A1.3 1.3 0 1 0 12 6.6 1.3 1.3 0 1 0 12 4ZM12 10.7A1.3 1.3 0 1 0 12 13.3 1.3 1.3 0 1 0 12 10.7ZM12 17.4A1.3 1.3 0 1 0 12 20 1.3 1.3 0 1 0 12 17.4ZM5.4 6.05A1.3 1.3 0 1 0 5.4 8.65 1.3 1.3 0 1 0 5.4 6.05ZM5.4 15.35A1.3 1.3 0 1 0 5.4 17.95 1.3 1.3 0 1 0 5.4 15.35ZM18.6 6.05A1.3 1.3 0 1 0 18.6 8.65 1.3 1.3 0 1 0 18.6 6.05ZM18.6 15.35A1.3 1.3 0 1 0 18.6 17.95 1.3 1.3 0 1 0 18.6 15.35Z";
|
||||
|
||||
public static string LogoDataFor(SocialService service) => service switch
|
||||
{
|
||||
SocialService.YouTube => YouTubePath,
|
||||
SocialService.Twitch => TwitchPath,
|
||||
SocialService.X => XPath,
|
||||
SocialService.Instagram => InstagramPath,
|
||||
SocialService.TikTok => TikTokPath,
|
||||
SocialService.Facebook => FacebookPath,
|
||||
SocialService.Discord => DiscordPath,
|
||||
SocialService.Kick => KickPath,
|
||||
SocialService.Threads => ThreadsPath,
|
||||
SocialService.Bluesky => BlueskyPath,
|
||||
SocialService.GitHub => GitHubPath,
|
||||
SocialService.LinkedIn => LinkedInPath,
|
||||
SocialService.Pinterest => PinterestPath,
|
||||
SocialService.Snapchat => SnapchatPath,
|
||||
SocialService.Reddit => RedditPath,
|
||||
SocialService.WhatsApp => WhatsAppPath,
|
||||
SocialService.Telegram => TelegramPath,
|
||||
SocialService.Fediverse => FediverseIconData,
|
||||
_ => LinkIconData,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The logo for a fediverse instance given its nodeinfo software name
|
||||
/// (e.g. "peertube", "mastodon"). Unknown software gets the generic
|
||||
/// fediverse glyph.
|
||||
/// </summary>
|
||||
public static string LogoDataForFediverse(string? software) => software?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"mastodon" => MastodonPath,
|
||||
"peertube" => PeerTubePath,
|
||||
"pixelfed" => PixelfedPath,
|
||||
"misskey" => MisskeyPath,
|
||||
"lemmy" => LemmyPath,
|
||||
"pleroma" => PleromaPath,
|
||||
"firefish" => FirefishPath,
|
||||
_ => FediverseIconData,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The canonical profile URL for a service handle. Fediverse handles
|
||||
/// (@user@domain) are recognized first — their identity carries its own host.
|
||||
/// </summary>
|
||||
public static string CanonicalUrlFor(SocialService service, string handle)
|
||||
{
|
||||
var input = handle.Trim();
|
||||
if (TryParseFediverse(input, out var fedUser, out var fedDomain))
|
||||
return $"https://{fedDomain}/@{fedUser}";
|
||||
var h = input.TrimStart('@');
|
||||
return service switch
|
||||
{
|
||||
SocialService.YouTube => $"https://www.youtube.com/@{h}",
|
||||
SocialService.Twitch => $"https://www.twitch.tv/{h}",
|
||||
SocialService.X => $"https://x.com/{h}",
|
||||
SocialService.Instagram => $"https://www.instagram.com/{h}",
|
||||
SocialService.TikTok => $"https://www.tiktok.com/@{h}",
|
||||
SocialService.Facebook => $"https://www.facebook.com/{h}",
|
||||
SocialService.Discord => $"https://discord.gg/{h}",
|
||||
SocialService.Kick => $"https://kick.com/{h}",
|
||||
SocialService.Threads => $"https://www.threads.net/@{h}",
|
||||
SocialService.Bluesky => $"https://bsky.app/profile/{h}",
|
||||
SocialService.GitHub => $"https://github.com/{h}",
|
||||
SocialService.LinkedIn => $"https://www.linkedin.com/in/{h}",
|
||||
SocialService.Pinterest => $"https://www.pinterest.com/{h}",
|
||||
SocialService.Snapchat => $"https://www.snapchat.com/add/{h}",
|
||||
SocialService.Reddit => $"https://www.reddit.com/user/{h}",
|
||||
SocialService.WhatsApp => $"https://wa.me/{h}",
|
||||
SocialService.Telegram => $"https://t.me/{h}",
|
||||
SocialService.Link or SocialService.Website => h.StartsWith("http") ? h : $"https://{h}",
|
||||
_ => $"https://{h}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detects the social service from a URL or fediverse handle. Supports
|
||||
/// full URLs (https://x.com/user), domain-only (x.com/user), and fediverse
|
||||
/// handles (@user@instance.tube). Returns Link/Website for unknown domains
|
||||
/// or bare handles — the caller can then ask which service it is.
|
||||
/// </summary>
|
||||
public static (SocialService service, string handle, string url) DetectService(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// Fediverse handle: @user@domain — the full handle IS the identity.
|
||||
if (TryParseFediverse(trimmed, out var fedUser, out var fedDomain))
|
||||
return (SocialService.Fediverse, trimmed, $"https://{fedDomain}/@{fedUser}");
|
||||
|
||||
// Strip protocol for domain parsing
|
||||
var url = trimmed;
|
||||
if (!trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
url = $"https://{trimmed}";
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var domain = uri.Host.ToLowerInvariant();
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
|
||||
return domain switch
|
||||
{
|
||||
"youtube.com" or "www.youtube.com" or "youtu.be" => (SocialService.YouTube, path, url),
|
||||
"twitch.tv" or "www.twitch.tv" => (SocialService.Twitch, path, url),
|
||||
"x.com" or "twitter.com" => (SocialService.X, path, url),
|
||||
"instagram.com" or "www.instagram.com" => (SocialService.Instagram, path, url),
|
||||
"tiktok.com" or "www.tiktok.com" => (SocialService.TikTok, path, url),
|
||||
"facebook.com" or "www.facebook.com" or "fb.com" => (SocialService.Facebook, path, url),
|
||||
"discord.gg" or "discord.com" => (SocialService.Discord, path, url),
|
||||
"kick.com" => (SocialService.Kick, path, url),
|
||||
"threads.net" or "www.threads.net" => (SocialService.Threads, path, url),
|
||||
"bsky.app" => (SocialService.Bluesky, path, url),
|
||||
"github.com" => (SocialService.GitHub, path, url),
|
||||
"linkedin.com" or "www.linkedin.com" => (SocialService.LinkedIn, path, url),
|
||||
"pinterest.com" or "www.pinterest.com" => (SocialService.Pinterest, path, url),
|
||||
"snapchat.com" => (SocialService.Snapchat, path, url),
|
||||
"reddit.com" or "www.reddit.com" => (SocialService.Reddit, path, url),
|
||||
"wa.me" or "whatsapp.com" => (SocialService.WhatsApp, path, url),
|
||||
"t.me" => (SocialService.Telegram, path, url),
|
||||
_ => (SocialService.Website, path, url),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
|
||||
public static bool TryParseFediverse(string input, out string user, out string domain)
|
||||
{
|
||||
user = domain = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
|
||||
var secondAt = input.IndexOf('@', 1);
|
||||
if (secondAt <= 1 || secondAt == input.Length - 1) return false;
|
||||
user = input[1..secondAt];
|
||||
domain = input[(secondAt + 1)..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
|
||||
|
||||
@@ -45,12 +45,13 @@ dotnet run
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite) |
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
|
||||
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
|
||||
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
|
||||
|
||||
## Docs (memory map)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
|
||||
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
|
||||
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
|
||||
/// does not flicker.
|
||||
/// </summary>
|
||||
public sealed class AudioLevelMeter
|
||||
{
|
||||
private const float Smoothing = 0.2f;
|
||||
private float _level;
|
||||
|
||||
public float Level => _level;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
|
||||
/// spread linearly across 0..1. Linear RMS of real speech or game audio is
|
||||
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking
|
||||
/// dead; the log scale makes typical levels occupy the bar.
|
||||
/// </summary>
|
||||
public static float ToDisplay(float linear)
|
||||
{
|
||||
if (linear <= 0.001f)
|
||||
return 0f;
|
||||
var db = 20f * MathF.Log10(linear);
|
||||
return Math.Clamp(1f + db / 60f, 0f, 1f);
|
||||
}
|
||||
|
||||
public float Push(AudioSample sample)
|
||||
{
|
||||
if (sample.Samples.Length == 0)
|
||||
return _level;
|
||||
|
||||
double sumSquares = 0;
|
||||
var count = 0;
|
||||
foreach (var value in sample.Samples)
|
||||
{
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
|
||||
var rms = (float)Math.Sqrt(sumSquares / count);
|
||||
_level = _level * (1 - Smoothing) + rms * Smoothing;
|
||||
return _level;
|
||||
}
|
||||
|
||||
public void Reset() => _level = 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
|
||||
/// the footer meters. Capture runs for the app's lifetime (started once at
|
||||
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
|
||||
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
|
||||
/// The mixer surfaces mic connection state (Connected/Failed) for the status
|
||||
/// dot and can restart the mic source mid-session when a device is re-picked.
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
private readonly IAudioSource _mic;
|
||||
private readonly IAudioSource _loopback;
|
||||
private readonly AudioLevelMeter _meter;
|
||||
private readonly AudioLevelMeter _loopbackMeter;
|
||||
private readonly Action<string>? _log;
|
||||
private bool _started;
|
||||
|
||||
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
_meter = new AudioLevelMeter();
|
||||
_loopbackMeter = new AudioLevelMeter();
|
||||
_log = log;
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
_loopback.SampleReady += OnLoopbackSample;
|
||||
_mic.Failed += OnMicFailed;
|
||||
_loopback.Failed += OnLoopbackFailed;
|
||||
}
|
||||
|
||||
/// <summary>Current smoothed mic level (0..1).</summary>
|
||||
public float MicLevel => _meter.Level;
|
||||
|
||||
/// <summary>Current smoothed desktop/game level (0..1).</summary>
|
||||
public float LoopbackLevel => _loopbackMeter.Level;
|
||||
|
||||
/// <summary>Raised whenever the smoothed mic level changes.</summary>
|
||||
public event Action<float>? MicLevelChanged;
|
||||
|
||||
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
|
||||
public event Action<float>? LoopbackLevelChanged;
|
||||
|
||||
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
|
||||
public event Action? MicConnected;
|
||||
|
||||
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
|
||||
public event Action<Exception>? MicFailed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
|
||||
_started = true;
|
||||
_meter.Reset();
|
||||
_loopback.Start();
|
||||
_mic.Start();
|
||||
}
|
||||
|
||||
/// <summary>Swaps the mic source without touching loopback — used when the
|
||||
/// creator picks a different device mid-session. The level resets and the
|
||||
/// new source raises <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
|
||||
public void RestartMic()
|
||||
{
|
||||
_mic.Stop();
|
||||
_meter.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
_mic.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
_mic.Stop();
|
||||
_loopback.Stop();
|
||||
_meter.Reset();
|
||||
_loopbackMeter.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
LoopbackLevelChanged?.Invoke(0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_mic.Started -= OnMicStarted;
|
||||
_mic.SampleReady -= OnMicSample;
|
||||
_loopback.SampleReady -= OnLoopbackSample;
|
||||
_mic.Failed -= OnMicFailed;
|
||||
_loopback.Failed -= OnLoopbackFailed;
|
||||
_mic.Dispose();
|
||||
_loopback.Dispose();
|
||||
}
|
||||
|
||||
private void OnMicStarted()
|
||||
{
|
||||
MicConnected?.Invoke();
|
||||
}
|
||||
|
||||
private void OnMicSample(AudioSample sample)
|
||||
{
|
||||
// Push unconditionally: the ?. on the event would otherwise skip the
|
||||
// argument (and the meter update) when nothing is subscribed yet.
|
||||
var level = _meter.Push(sample);
|
||||
MicLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
private void OnLoopbackSample(AudioSample sample)
|
||||
{
|
||||
var level = _loopbackMeter.Push(sample);
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||
MicLevelChanged?.Invoke(0);
|
||||
MicFailed?.Invoke(ex);
|
||||
}
|
||||
|
||||
private void OnLoopbackFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A chunk of interleaved PCM float samples (-1..1) with its format. This is
|
||||
/// the unit every <see cref="IAudioSource"/> produces and the
|
||||
/// <see cref="AudioMixer"/> consumes (TASK 4 ship step 4).
|
||||
/// </summary>
|
||||
public sealed class AudioSample
|
||||
{
|
||||
public float[] Samples { get; }
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
|
||||
public AudioSample(float[] samples, int sampleRate, int channels)
|
||||
{
|
||||
Samples = samples;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
|
||||
/// chunks. The default implementations wrap NAudio's WASAPI capture (mic) /
|
||||
/// loopback (desktop/game); the mixer and the tests consume this interface,
|
||||
/// never NAudio directly. Capture now runs for the app's lifetime so the
|
||||
/// footer meters stay live in preview — the mixer owns start/stop.
|
||||
/// </summary>
|
||||
public interface IAudioSource : IDisposable
|
||||
{
|
||||
/// <summary>Starts capturing. Safe to call only once per Stop.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
|
||||
void Stop();
|
||||
|
||||
/// <summary>Raises once capture is live (right after recording starts).
|
||||
/// Never raised when Start fails — <see cref="Failed"/> fires instead.</summary>
|
||||
event Action? Started;
|
||||
|
||||
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
|
||||
event Action<AudioSample>? SampleReady;
|
||||
|
||||
/// <summary>Raises when capture dies or fails to start (e.g. no device).</summary>
|
||||
event Action<Exception>? Failed;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the user's desktop/game audio (TASK 4 ship step 4) via WASAPI
|
||||
/// loopback on the default render device. Runs for the app's lifetime so the
|
||||
/// game audio bar stays live in preview.
|
||||
/// </summary>
|
||||
public sealed class WasapiLoopbackAudioSource : IAudioSource
|
||||
{
|
||||
private WasapiLoopbackCapture? _capture;
|
||||
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
_capture = new WasapiLoopbackCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
Started?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
|
||||
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
|
||||
/// persists DisplayName), falling back to the default capture endpoint.
|
||||
/// </summary>
|
||||
public sealed class WasapiMicAudioSource : IAudioSource
|
||||
{
|
||||
private readonly Func<string?> _micNameProvider;
|
||||
private WasapiCapture? _capture;
|
||||
|
||||
/// <param name="micNameProvider">Returns the current mic DisplayName; read
|
||||
/// at each Start so a device picked mid-session takes effect immediately
|
||||
/// (the mixer restarts the mic on pick).</param>
|
||||
public WasapiMicAudioSource(Func<string?> micNameProvider)
|
||||
{
|
||||
_micNameProvider = micNameProvider;
|
||||
}
|
||||
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
var device = ResolveDevice();
|
||||
_capture = device != null ? new WasapiCapture(device) : new WasapiCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
Started?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private MMDevice? ResolveDevice()
|
||||
{
|
||||
var micName = _micNameProvider();
|
||||
if (string.IsNullOrWhiteSpace(micName))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
foreach (var endpoint in enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active))
|
||||
{
|
||||
if (string.Equals(endpoint.FriendlyName, micName, StringComparison.OrdinalIgnoreCase))
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NAudio.Dmo;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Converts NAudio's raw WASAPI capture buffers (byte[], whatever bit depth the
|
||||
/// device's mix format reports) into interleaved PCM float samples. Pure —
|
||||
/// unit-tested; the two WASAPI sources share it.
|
||||
/// </summary>
|
||||
public static class WaveToFloat
|
||||
{
|
||||
public static float[] Convert(byte[] buffer, int bytesRecorded, WaveFormat format)
|
||||
{
|
||||
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
if (format is WaveFormatExtensible extensible
|
||||
&& extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
}
|
||||
|
||||
private static float[] ConvertIeeeFloat(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 4;
|
||||
var result = new float[count];
|
||||
Buffer.BlockCopy(buffer, 0, result, 0, count * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float[] ConvertPcm16(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 2;
|
||||
var result = new float[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
result[i] = BitConverter.ToInt16(buffer, i * 2) / 32768f;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ namespace ytLive.Services;
|
||||
/// disposes the source. Frames arrive on a worker thread and are coalesced onto
|
||||
/// the UI dispatcher (at most one pending copy per session, using the latest
|
||||
/// frame) so a 60fps device doesn't drown the render thread.
|
||||
///
|
||||
/// Allocation is proven, never assumed: <see cref="AcquireAsync"/> treats a
|
||||
/// started reader as success only once the first frame actually arrives (within
|
||||
/// <paramref name="firstFrameTimeout"/>), and an async <see cref="SourceFailed"/>
|
||||
/// tears the session down and surfaces <see cref="CameraFailed"/> — no silent
|
||||
/// empty box.
|
||||
/// </summary>
|
||||
public sealed class CameraManager : IDisposable
|
||||
{
|
||||
@@ -23,11 +29,13 @@ public sealed class CameraManager : IDisposable
|
||||
public string DeviceId { get; }
|
||||
public ICameraFrameSource Source { get; }
|
||||
public Action<VideoFrame>? FrameHandler;
|
||||
public Action<string>? FailureHandler;
|
||||
public int RefCount;
|
||||
public bool Started;
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool FramePending;
|
||||
public TaskCompletionSource<bool>? FirstFrame;
|
||||
|
||||
public CameraSession(string deviceId, ICameraFrameSource source)
|
||||
{
|
||||
@@ -40,21 +48,26 @@ public sealed class CameraManager : IDisposable
|
||||
private readonly ICameraEnumerator _enumerator;
|
||||
private readonly Func<string, ICameraFrameSource> _frameSourceFactory;
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly TimeSpan _firstFrameTimeout;
|
||||
private readonly Dictionary<string, CameraSession> _sessions = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
|
||||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||
|
||||
/// <summary>Raised when a capture fails to start (device in use, access denied, no preview source).</summary>
|
||||
/// <summary>
|
||||
/// Raised when a capture fails: device in use, access denied, no preview source,
|
||||
/// or a started reader that never delivers a first frame within the timeout.
|
||||
/// </summary>
|
||||
public event Action<string, string>? CameraFailed;
|
||||
|
||||
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
|
||||
Dispatcher? uiDispatcher = null)
|
||||
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
|
||||
{
|
||||
_enumerator = enumerator;
|
||||
_frameSourceFactory = frameSourceFactory;
|
||||
_uiDispatcher = uiDispatcher;
|
||||
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
|
||||
}
|
||||
|
||||
public ICameraEnumerator Enumerator => _enumerator;
|
||||
@@ -79,6 +92,8 @@ public sealed class CameraManager : IDisposable
|
||||
session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
|
||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||
session.Source.FrameAvailable += session.FrameHandler;
|
||||
session.FailureHandler = message => OnSourceFailed(session, message);
|
||||
session.Source.SourceFailed += session.FailureHandler;
|
||||
_sessions[deviceId] = session;
|
||||
shouldStart = true;
|
||||
}
|
||||
@@ -86,20 +101,32 @@ public sealed class CameraManager : IDisposable
|
||||
|
||||
if (!shouldStart) return session.Started;
|
||||
|
||||
session.FirstFrame = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
await session.Source.StartAsync();
|
||||
|
||||
// First-frame proof: "the reader started" is not "the stream is live".
|
||||
// Success requires an actual frame within the timeout, else the session
|
||||
// is rolled back and reported — never left as a silent empty preview.
|
||||
var proven = _firstFrameTimeout <= TimeSpan.Zero
|
||||
|| await WaitForFirstFrameAsync(session, _firstFrameTimeout);
|
||||
if (!proven)
|
||||
{
|
||||
var reason = $"Camera '{deviceId}' started but produced no frames within {_firstFrameTimeout.TotalSeconds:0.#}s.";
|
||||
RollbackSession(session, reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep the SourceFailed handler subscribed: an async failure after a
|
||||
// successful start (device lost, stream state Failed) must still surface.
|
||||
session.FirstFrame = null;
|
||||
session.Started = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
_sessions.Remove(deviceId);
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
AppLog.Write($"CameraManager: failed to start camera '{deviceId}': {ex.Message}");
|
||||
CameraFailed?.Invoke(deviceId, ex.Message);
|
||||
await SafeStopAsync(session.Source);
|
||||
RollbackSession(session, ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -174,10 +201,47 @@ public sealed class CameraManager : IDisposable
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
if (session.FailureHandler != null)
|
||||
session.Source.SourceFailed -= session.FailureHandler;
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForFirstFrameAsync(CameraSession session, TimeSpan timeout)
|
||||
{
|
||||
var first = session.FirstFrame;
|
||||
if (first == null) return true;
|
||||
var completed = await Task.WhenAny(first.Task, Task.Delay(timeout));
|
||||
return ReferenceEquals(completed, first.Task) && first.Task.Result;
|
||||
}
|
||||
|
||||
private void RollbackSession(CameraSession session, string message)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (TryGetActiveSession(session))
|
||||
_sessions.Remove(session.DeviceId);
|
||||
}
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
if (session.FailureHandler != null)
|
||||
session.Source.SourceFailed -= session.FailureHandler;
|
||||
session.FirstFrame?.TrySetResult(false);
|
||||
|
||||
var enriched = message;
|
||||
var suspects = CameraConflictProbe.GetRunningCameraApps();
|
||||
if (suspects.Count > 0)
|
||||
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
|
||||
|
||||
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
|
||||
CameraFailed?.Invoke(session.DeviceId, enriched);
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
|
||||
private void OnSourceFailed(CameraSession session, string message)
|
||||
{
|
||||
RollbackSession(session, message);
|
||||
}
|
||||
|
||||
private static async Task SafeStopAsync(ICameraFrameSource source)
|
||||
{
|
||||
try
|
||||
@@ -200,6 +264,7 @@ public sealed class CameraManager : IDisposable
|
||||
{
|
||||
if (!TryGetActiveSession(session)) return;
|
||||
session.LatestFrame = frame;
|
||||
session.FirstFrame?.TrySetResult(true);
|
||||
|
||||
if (session.PreviewBitmap == null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Where the compositor draws from (the active quality tier's output rect over the
|
||||
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
|
||||
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
|
||||
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
|
||||
/// rounds before building these options.
|
||||
/// </summary>
|
||||
public sealed class CompositorOptions
|
||||
{
|
||||
public int SourceRectX { get; init; }
|
||||
public int SourceRectY { get; init; }
|
||||
public int SourceRectWidth { get; init; }
|
||||
public int SourceRectHeight { get; init; }
|
||||
|
||||
public int OutputWidth { get; init; }
|
||||
public int OutputHeight { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
|
||||
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
|
||||
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
///
|
||||
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
|
||||
/// the compositor pure and free of WPF and of the capture managers.
|
||||
/// </summary>
|
||||
public sealed class SceneCompositor
|
||||
{
|
||||
/// <summary>
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
Scene scene,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
VideoFrame? flashFrame,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
if (options.SourceRectWidth <= 0 || options.SourceRectHeight <= 0)
|
||||
throw new ArgumentException("The source rect must be positive.", nameof(options));
|
||||
if (options.OutputWidth <= 0 || options.OutputHeight <= 0)
|
||||
throw new ArgumentException("The output size must be positive.", nameof(options));
|
||||
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base — video frames are never transparent
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var backdrop = elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
|
||||
var backdropFrame = backdrop != null ? frameFor(backdrop) : null;
|
||||
if (backdropFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backdropFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackdrop: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue; // backdrop/background are their own layers; Text isn't shipped
|
||||
}
|
||||
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
|
||||
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
|
||||
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
|
||||
/// horizontal mirror around the element center.
|
||||
/// </summary>
|
||||
private static void BlitContent(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
VideoFrame src, float opacity, bool isRound, bool isMirror)
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
|
||||
var x0 = Math.Max(0, (int)Math.Floor(ex));
|
||||
var y0 = Math.Max(0, (int)Math.Floor(ey));
|
||||
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
|
||||
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
|
||||
if (x0 > x1 || y0 > y1) return;
|
||||
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
var drawnW = src.Width * scale;
|
||||
var drawnH = src.Height * scale;
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
var cx = ew / 2f;
|
||||
var cy = eh / 2f;
|
||||
|
||||
for (var y = y0; y <= y1; y++)
|
||||
{
|
||||
for (var x = x0; x <= x1; x++)
|
||||
{
|
||||
var px = x - ex; // element space
|
||||
var py = y - ey;
|
||||
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
|
||||
if (isRound)
|
||||
{
|
||||
var dx = px - cx;
|
||||
var dy = py - cy;
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
}
|
||||
|
||||
var sx = (px - ox) / scale;
|
||||
var sy = (py - oy) / scale;
|
||||
if (isMirror) sx = src.Width - 1 - sx;
|
||||
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).</summary>
|
||||
private static void DrawBorder(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
SceneElement element, bool isRound)
|
||||
{
|
||||
if (!element.TryGetBorderColor(out var r, out var g, out var b)) return;
|
||||
var half = element.BorderWidth / 2f;
|
||||
var alpha = (float)(element.Opacity * element.BorderOpacity);
|
||||
if (half <= 0 || alpha <= 0) return;
|
||||
|
||||
var cx = ex + ew / 2f;
|
||||
var cy = ey + eh / 2f;
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
|
||||
var x0 = Math.Max(0, (int)Math.Floor(ex - half));
|
||||
var y0 = Math.Max(0, (int)Math.Floor(ey - half));
|
||||
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew + half));
|
||||
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh + half));
|
||||
|
||||
for (var y = y0; y <= y1; y++)
|
||||
{
|
||||
for (var x = x0; x <= x1; x++)
|
||||
{
|
||||
float d = isRound
|
||||
? MathF.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) - radius
|
||||
: MathF.Max(MathF.Max(ex - x, x - (ex + ew)), MathF.Max(ey - y, y - (ey + eh)));
|
||||
if (MathF.Abs(d) <= half)
|
||||
BlendPixel(dst, (y * dstW + x) * 4, (b, g, r, (byte)255), alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
|
||||
private static void BlitOverlay(
|
||||
byte[] dst, int dstW, int dstH, CompositorOptions options,
|
||||
VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
for (var y = 0; y < dstH; y++)
|
||||
{
|
||||
for (var x = 0; x < dstW; x++)
|
||||
{
|
||||
var sx = x + options.SourceRectX - sx0;
|
||||
var sy = y + options.SourceRectY - sy0;
|
||||
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
|
||||
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
|
||||
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
|
||||
{
|
||||
var sa = src.A / 255f * opacity;
|
||||
if (sa <= 0) return;
|
||||
var da = dst[di + 3] / 255f;
|
||||
var outA = sa + da * (1 - sa);
|
||||
if (outA <= 0) return;
|
||||
dst[di] = (byte)Math.Round((src.B * sa + dst[di] * da * (1 - sa)) / outA);
|
||||
dst[di + 1] = (byte)Math.Round((src.G * sa + dst[di + 1] * da * (1 - sa)) / outA);
|
||||
dst[di + 2] = (byte)Math.Round((src.R * sa + dst[di + 2] * da * (1 - sa)) / outA);
|
||||
dst[di + 3] = (byte)Math.Round(outA * 255);
|
||||
}
|
||||
}
|
||||
@@ -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,48 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a layout asset's bytes into a tightly-packed BGRA8 <see cref="VideoFrame"/>
|
||||
/// once per content-hash asset id. The preview uses the WPF <c>BitmapImage</c> in
|
||||
/// ImageCache; the output path needs raw pixels, so assets decode here instead.
|
||||
/// </summary>
|
||||
public static class StaticPixelCache
|
||||
{
|
||||
private static readonly Dictionary<string, VideoFrame> Cache = new(StringComparer.Ordinal);
|
||||
|
||||
public static VideoFrame? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
|
||||
var decoded = Decode(bytes);
|
||||
if (decoded != null) Cache[assetId] = decoded;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
public static VideoFrame? Decode(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(bytes, writable: false);
|
||||
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||
var source = decoder.Frames[0];
|
||||
var bgra = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
|
||||
var width = bgra.PixelWidth;
|
||||
var height = bgra.PixelHeight;
|
||||
var pixels = new byte[width * height * 4];
|
||||
bgra.CopyPixels(pixels, width * 4, 0);
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Pure pixel math shared by the compositor: the WPF "UniformToFill" cover-crop
|
||||
/// (what the preview's <c>Stretch="UniformToFill"</c> does) and a clamped bilinear
|
||||
/// sample/scale. Pure and deterministic — the unit-tested half of the compositor.
|
||||
/// </summary>
|
||||
public static class StretchMath
|
||||
{
|
||||
/// <summary>
|
||||
/// UniformToFill: the drawn content covers the destination while preserving the
|
||||
/// source aspect, centered; overflow is cropped. Returns the scale and the
|
||||
/// source-origin offset (in destination pixels) of the drawn content within the
|
||||
/// destination rect.
|
||||
/// </summary>
|
||||
public static (float Scale, float OffsetX, float OffsetY) UniformToFill(float dstW, float dstH, int srcW, int srcH)
|
||||
{
|
||||
var scale = Math.Max(dstW / srcW, dstH / srcH);
|
||||
var drawnW = srcW * scale;
|
||||
var drawnH = srcH * scale;
|
||||
return (scale, (dstW - drawnW) / 2f, (dstH - drawnH) / 2f);
|
||||
}
|
||||
|
||||
/// <summary>Clamped bilinear scale into a new tightly-packed BGRA8 frame; returns the input unchanged when the sizes already match.</summary>
|
||||
public static VideoFrame BilinearScale(VideoFrame src, int outW, int outH)
|
||||
{
|
||||
if (outW == src.Width && outH == src.Height) return src;
|
||||
|
||||
var outPixels = new byte[outW * outH * 4];
|
||||
for (var y = 0; y < outH; y++)
|
||||
{
|
||||
var sy = (y + 0.5f) / outH * src.Height - 0.5f;
|
||||
for (var x = 0; x < outW; x++)
|
||||
{
|
||||
var sx = (x + 0.5f) / outW * src.Width - 0.5f;
|
||||
var (b, g, r, a) = SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||
var di = (y * outW + x) * 4;
|
||||
outPixels[di] = b;
|
||||
outPixels[di + 1] = g;
|
||||
outPixels[di + 2] = r;
|
||||
outPixels[di + 3] = a;
|
||||
}
|
||||
}
|
||||
return new VideoFrame(outW, outH, outPixels);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamped bilinear sample of one BGRA8 pixel at fractional (sx, sy). Coordinates
|
||||
/// outside [0, w-1]×[0, h-1] clamp to the edge, so a caller can sample freely
|
||||
/// without bounds checks.
|
||||
/// </summary>
|
||||
public static (byte B, byte G, byte R, byte A) SampleBgra(byte[] src, int w, int h, float sx, float sy)
|
||||
{
|
||||
sx = Math.Clamp(sx, 0, w - 1);
|
||||
sy = Math.Clamp(sy, 0, h - 1);
|
||||
var x0 = (int)sx;
|
||||
var y0 = (int)sy;
|
||||
var x1 = Math.Min(x0 + 1, w - 1);
|
||||
var y1 = Math.Min(y0 + 1, h - 1);
|
||||
var fx = sx - x0;
|
||||
var fy = sy - y0;
|
||||
|
||||
var p00 = (y0 * w + x0) * 4;
|
||||
var p10 = (y0 * w + x1) * 4;
|
||||
var p01 = (y1 * w + x0) * 4;
|
||||
var p11 = (y1 * w + x1) * 4;
|
||||
|
||||
float r = Lerp(Lerp(src[p00 + 2], src[p10 + 2], fx), Lerp(src[p01 + 2], src[p11 + 2], fx), fy);
|
||||
float g = Lerp(Lerp(src[p00 + 1], src[p10 + 1], fx), Lerp(src[p01 + 1], src[p11 + 1], fx), fy);
|
||||
float b = Lerp(Lerp(src[p00], src[p10], fx), Lerp(src[p01], src[p11], fx), fy);
|
||||
float a = Lerp(Lerp(src[p00 + 3], src[p10 + 3], fx), Lerp(src[p01 + 3], src[p11 + 3], fx), fy);
|
||||
return ((byte)Math.Round(b), (byte)Math.Round(g), (byte)Math.Round(r), (byte)Math.Round(a));
|
||||
}
|
||||
|
||||
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
|
||||
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
|
||||
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/<key></c>).
|
||||
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
|
||||
/// placeholder track until the WASAPI capture step replaces the input.
|
||||
/// </summary>
|
||||
public sealed class EncoderOptions
|
||||
{
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
public int Fps { get; init; } = 60;
|
||||
public int BitrateKbps { get; init; } = 8000;
|
||||
|
||||
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
|
||||
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
|
||||
public string? VideoEncoder { get; init; }
|
||||
|
||||
public int AudioSampleRate { get; init; } = 48000;
|
||||
public int AudioChannels { get; init; } = 2;
|
||||
|
||||
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
|
||||
public int GopSize => Fps * 4;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
|
||||
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
|
||||
/// muxing to the ingestion URL. Pure — the encoder just starts
|
||||
/// <c>ffmpeg.exe [Build(...)]</c>.
|
||||
/// </summary>
|
||||
public static class FfmpegArgs
|
||||
{
|
||||
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
return
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel", "info",
|
||||
"-stats",
|
||||
"-stats_period", "0.5",
|
||||
"-re",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "lavfi",
|
||||
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
|
||||
"-c:v", videoEncoder,
|
||||
"-b:v", $"{options.BitrateKbps}k",
|
||||
"-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k",
|
||||
"-g", gop.ToString(),
|
||||
"-keyint_min", gop.ToString(),
|
||||
"-sc_threshold", "0",
|
||||
"-bf", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", options.AudioSampleRate.ToString(),
|
||||
"-ac", options.AudioChannels.ToString(),
|
||||
"-f", "flv",
|
||||
options.RtmpUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Diagnostics;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IFfmpegEncoder"/>: spawns <c>ffmpeg.exe</c> (resolved via
|
||||
/// <see cref="IFfmpegLocator"/>), feeds raw BGRA frames into stdin, and parses the
|
||||
/// <c>-stats</c> progress lines into <see cref="StreamHealth"/>. Encoder choice is
|
||||
/// probed from the binary's <c>-encoders</c> listing (hardware NVENC/QSV/AMF first,
|
||||
/// OpenH264 software fallback — never libx264, see the license posture) unless
|
||||
/// <see cref="EncoderOptions.VideoEncoder"/> forces one.
|
||||
///
|
||||
/// Graceful stop = close stdin (EOF) → ffmpeg finalizes the FLV and exits by itself;
|
||||
/// a watchdogs kill fires only if it hasn't exited shortly after EOF.
|
||||
/// </summary>
|
||||
public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
{
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
public event EventHandler<string>? ProcessFailed;
|
||||
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IEncoderProcess> _processFactory;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IEncoderProcess? _process;
|
||||
private EncoderOptions? _options;
|
||||
private StreamHealth _health = new() { Status = StreamStatus.Offline };
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
public FfmpegEncoder(
|
||||
IFfmpegLocator locator,
|
||||
Func<IEncoderProcess>? processFactory = null)
|
||||
{
|
||||
_locator = locator;
|
||||
_processFactory = processFactory ?? (() => new FfmpegEncoderProcess());
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
|
||||
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (IsRunning) throw new InvalidOperationException("The encoder is already running.");
|
||||
_options = options;
|
||||
}
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
|
||||
var args = FfmpegArgs.Build(options, encoder);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args) startInfo.ArgumentList.Add(arg);
|
||||
|
||||
IEncoderProcess process;
|
||||
try
|
||||
{
|
||||
process = _processFactory();
|
||||
process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
|
||||
lock (_gate) _options = null;
|
||||
throw;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_process = process;
|
||||
IsRunning = true;
|
||||
_health = new StreamHealth { Status = StreamStatus.Streaming };
|
||||
}
|
||||
|
||||
_stopRequested = false;
|
||||
_stderrLoop = RunStderrLoopAsync(process);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
|
||||
/// (the compositor pump) may race freely. Frames are written as-is — the caller
|
||||
/// paces to capture rate (the compositor's job, ship step 5).
|
||||
/// </summary>
|
||||
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frame == null) throw new ArgumentNullException(nameof(frame));
|
||||
IEncoderProcess? process;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
|
||||
process = _process;
|
||||
}
|
||||
|
||||
var bytes = frame.BgraPixels;
|
||||
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEncoderProcess? process;
|
||||
Task? loop;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
_stopRequested = true;
|
||||
process = _process;
|
||||
loop = _stderrLoop;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
await process!.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
|
||||
process!.Kill();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
|
||||
process!.Kill();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loop != null) await loop.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
IsRunning = false;
|
||||
_health.Status = StreamStatus.Offline;
|
||||
_health.LastError = null;
|
||||
_process = null;
|
||||
_options = null;
|
||||
}
|
||||
|
||||
AppLog.Write($"FFmpeg encoder stopped (exit {process.ExitCode})");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
_process?.Kill();
|
||||
_process?.Dispose();
|
||||
_process = null;
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-encoders");
|
||||
|
||||
using var probe = _processFactory();
|
||||
probe.Start(startInfo);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
var output = await probe.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
return FfmpegEncoderPicker.Pick(output);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
|
||||
return FfmpegEncoderPicker.Preference[^1];
|
||||
}
|
||||
}
|
||||
|
||||
private Task RunStderrLoopAsync(IEncoderProcess process)
|
||||
{
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
|
||||
if (line == null) break;
|
||||
OnStderrLine(process, line);
|
||||
}
|
||||
|
||||
var code = process.ExitCode;
|
||||
var stillRunning = false;
|
||||
lock (_gate) stillRunning = IsRunning;
|
||||
if (stillRunning && !_stopRequested && code != 0)
|
||||
{
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
|
||||
_health.LastError = $"FFmpeg exited with code {code}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnStderrLine(IEncoderProcess process, string line)
|
||||
{
|
||||
var progress = FfmpegProgressParser.TryParse(line);
|
||||
if (progress == null) return;
|
||||
|
||||
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_health.CurrentBitrate = progress.Value.BitrateKbps;
|
||||
_health.FPS = progress.Value.Fps;
|
||||
_health.DroppedFrames = (int)dropped;
|
||||
_health.StreamDuration = progress.Value.Duration;
|
||||
}
|
||||
|
||||
HealthUpdated?.Invoke(this, _health);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the best available H.264 encoder from ffmpeg's <c>-encoders</c> listing,
|
||||
/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then
|
||||
/// the OpenH264 software fallback. Pure parser — the <c>-encoders</c> probe output is
|
||||
/// fetched by the encoder via an <see cref="IEncoderProcess"/> and fed here.
|
||||
/// </summary>
|
||||
public static class FfmpegEncoderPicker
|
||||
{
|
||||
/// <summary>Preference order, best first. All ship in the pinned BtbN lgpl-shared build.</summary>
|
||||
public static readonly string[] Preference =
|
||||
[
|
||||
"h264_nvenc",
|
||||
"h264_qsv",
|
||||
"h264_amf",
|
||||
"libopenh264",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// First <see cref="Preference"/> entry present in the probe output, or the
|
||||
/// software fallback (which the pinned build always contains) if none matched.
|
||||
/// Never returns libx264 — it is GPL and would contaminate the paid product.
|
||||
/// </summary>
|
||||
public static string Pick(string probeOutput)
|
||||
{
|
||||
var available = probeOutput.Split('\n');
|
||||
foreach (var name in Preference)
|
||||
{
|
||||
if (available.Any(line => line.Contains(name, StringComparison.Ordinal)))
|
||||
return name;
|
||||
}
|
||||
return Preference[^1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="IEncoderProcess"/>: a <see cref="Process"/> with all three
|
||||
/// std streams redirected. Constructed by <see cref="FfmpegEncoder"/> for both the
|
||||
/// encoder subprocess and the <c>-encoders</c> probe.
|
||||
/// </summary>
|
||||
public sealed class FfmpegEncoderProcess : IEncoderProcess
|
||||
{
|
||||
private readonly Process _process;
|
||||
|
||||
public FfmpegEncoderProcess() => _process = new Process { EnableRaisingEvents = true };
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
_process.StartInfo = startInfo;
|
||||
_process.Start();
|
||||
}
|
||||
|
||||
public Stream StandardInput => _process.StandardInput.BaseStream;
|
||||
public TextReader StandardOutput => _process.StandardOutput;
|
||||
public TextReader StandardError => _process.StandardError;
|
||||
|
||||
public bool HasExited => _process.HasExited;
|
||||
public int ExitCode => _process.ExitCode;
|
||||
public void Kill()
|
||||
{
|
||||
if (!_process.HasExited) _process.Kill();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
|
||||
=> _process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
public void Dispose() => _process.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
|
||||
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
|
||||
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
|
||||
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
|
||||
/// text + source offer", with no static-relink (LGPL §6) material required. The
|
||||
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
|
||||
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
|
||||
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
|
||||
/// fake the network and stay on a temp directory.
|
||||
/// </summary>
|
||||
public sealed class FfmpegLocator : IFfmpegLocator
|
||||
{
|
||||
public const string FileName = "ffmpeg.exe";
|
||||
|
||||
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
|
||||
public const string PinnedUrl =
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
|
||||
|
||||
private readonly string[] _searchDirs;
|
||||
private readonly string _toolsDir;
|
||||
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
|
||||
|
||||
public FfmpegLocator(
|
||||
string[]? searchDirs = null,
|
||||
string? toolsDir = null,
|
||||
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
|
||||
{
|
||||
_searchDirs = searchDirs ?? ParsePath();
|
||||
_toolsDir = toolsDir ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
|
||||
_downloader = downloader ?? DefaultDownload;
|
||||
}
|
||||
|
||||
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var dir in _searchDirs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
var candidate = Path.Combine(dir, FileName);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
|
||||
var cached = Path.Combine(_toolsDir, FileName);
|
||||
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (zip.Length == 0)
|
||||
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
|
||||
Directory.CreateDirectory(_toolsDir);
|
||||
ExtractBinaries(zip, _toolsDir);
|
||||
return cached;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
|
||||
/// staging directory, so a failed extract never leaves a partially-populated
|
||||
/// cache behind (the previous good cache stays until every move succeeds).
|
||||
/// </summary>
|
||||
private static void ExtractBinaries(byte[] zip, string toolsDir)
|
||||
{
|
||||
var staging = toolsDir + ".stage";
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
Directory.CreateDirectory(staging);
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(zip, writable: false);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
var exe = archive.Entries.FirstOrDefault(
|
||||
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
|
||||
ExtractOne(exe, Path.Combine(staging, FileName));
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
|
||||
}
|
||||
foreach (var file in Directory.GetFiles(staging))
|
||||
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractOne(ZipArchiveEntry entry, string destination)
|
||||
{
|
||||
var temp = destination + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var source = entry.Open())
|
||||
using (var target = File.Create(temp))
|
||||
source.CopyTo(target);
|
||||
File.Move(temp, destination);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp)) File.Delete(temp);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string[] ParsePath()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>A decoded ffmpeg <c>-stats</c> progress line (pure data).</summary>
|
||||
public readonly record struct FfmpegProgress(
|
||||
long Frame,
|
||||
double Fps,
|
||||
double BitrateKbps,
|
||||
TimeSpan Duration,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser for ffmpeg's periodic <c>frame= fps= size= time= bitrate=</c> stderr
|
||||
/// lines (the <c>-stats</c>/<c>-stats_period</c> output). Unit-tested in isolation
|
||||
/// so the encoder loop stays a thin wire.
|
||||
/// </summary>
|
||||
public static class FfmpegProgressParser
|
||||
{
|
||||
// frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x
|
||||
private static readonly Regex Line = new(
|
||||
@"frame=\s*(?<frame>\d+)\s+fps=\s*(?<fps>[\d.]+).*?"
|
||||
+ @"size=\s*(?<size>\d+)KiB.*?"
|
||||
+ @"time=(?<time>\d{2}):(?<min>\d{2}):(?<sec>[\d.]+).*?"
|
||||
+ @"bitrate=\s*(?<bitrate>[\d.]+)kbits/s",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Returns null for non-progress lines (errors, warnings, banner).</summary>
|
||||
public static FfmpegProgress? TryParse(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||
var m = Line.Match(line);
|
||||
if (!m.Success) return null;
|
||||
|
||||
var frame = long.Parse(m.Groups["frame"].Value);
|
||||
var fps = double.Parse(m.Groups["fps"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var size = long.Parse(m.Groups["size"].Value);
|
||||
var bitrate = double.Parse(m.Groups["bitrate"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var hours = int.Parse(m.Groups["time"].Value);
|
||||
var minutes = int.Parse(m.Groups["min"].Value);
|
||||
var seconds = double.Parse(m.Groups["sec"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var duration = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
|
||||
|
||||
return new FfmpegProgress(frame, fps, bitrate, duration, size * 1024);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
///
|
||||
/// The RTMP URL comes from the options provider: until the live-stream create
|
||||
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
|
||||
/// without actually pushing.
|
||||
/// </summary>
|
||||
public sealed class FramePump : IDisposable
|
||||
{
|
||||
private readonly Func<Scene?> _sceneProvider;
|
||||
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
|
||||
private readonly Func<CompositorOptions> _compositorOptions;
|
||||
private readonly Func<EncoderOptions?> _encoderOptions;
|
||||
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public FramePump(
|
||||
Func<Scene?> sceneProvider,
|
||||
Func<SceneElement, VideoFrame?> frameResolver,
|
||||
Func<CompositorOptions> compositorOptions,
|
||||
Func<EncoderOptions?> encoderOptions,
|
||||
Func<IFfmpegEncoder> encoderFactory,
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
|
||||
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
|
||||
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
}
|
||||
|
||||
IFfmpegEncoder? encoder = null;
|
||||
try
|
||||
{
|
||||
var options = _encoderOptions();
|
||||
if (options == null)
|
||||
{
|
||||
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
|
||||
lock (_gate) _started = false;
|
||||
return;
|
||||
}
|
||||
|
||||
encoder = _encoderFactory();
|
||||
encoder.HealthUpdated += OnHealthUpdated;
|
||||
encoder.ProcessFailed += OnProcessFailed;
|
||||
await encoder.StartAsync(options, cancellationToken);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = encoder;
|
||||
}
|
||||
|
||||
// IsRunning must be true before the loop starts: the loop reads it on
|
||||
// its first iteration, and with a completed-task delay it can run
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: start failed: {ex.Message}");
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
}
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IFfmpegEncoder? encoder;
|
||||
Task? pump;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started && _encoder == null) return;
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
encoder = _encoder;
|
||||
pump = _pumpTask;
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
|
||||
// write stuck on pipe backpressure, otherwise the pump could await forever.
|
||||
if (encoder != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await encoder.StopAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pump != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pump;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = null;
|
||||
_cts = null;
|
||||
_pumpTask = null;
|
||||
}
|
||||
_log?.Invoke("FramePump stopped");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
StopAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
var compositorOptions = _compositorOptions();
|
||||
VideoFrame? socialBarFrame = null;
|
||||
var socialBarTop = 0;
|
||||
if (_socialBar != null)
|
||||
{
|
||||
var (barFrame, position) = _socialBar();
|
||||
socialBarFrame = barFrame;
|
||||
if (barFrame != null)
|
||||
socialBarTop = position == SocialBarPosition.Top
|
||||
? 0
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
var frame = _compositor.Render(
|
||||
scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// normal stop
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failure while the pump is supposed to run (encoder died under us,
|
||||
// scene provider faulted, ...) stops the pump and surfaces once.
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
Failed?.Invoke(this, message);
|
||||
_ = StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Seam around a spawned subprocess (the encoder's ffmpeg and the encoder probe).
|
||||
/// Exposes the redirected stdin (binary frames), stdout (probe listing) and stderr
|
||||
/// (progress lines) plus exit control, so the encoder logic never touches
|
||||
/// <c>System.Diagnostics.Process</c> directly and tests can fake the whole thing.
|
||||
/// </summary>
|
||||
public interface IEncoderProcess : IDisposable
|
||||
{
|
||||
void Start(ProcessStartInfo startInfo);
|
||||
|
||||
/// <summary>Raw binary stdin — the encoder writes BGRA frames here.</summary>
|
||||
Stream StandardInput { get; }
|
||||
|
||||
TextReader StandardOutput { get; }
|
||||
TextReader StandardError { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
int ExitCode { get; }
|
||||
void Kill();
|
||||
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The live encoder seam (TASK 4 ship step 3): start an FFmpeg subprocess that
|
||||
/// encodes raw BGRA frames from stdin and pushes FLV to an RTMP ingestion URL,
|
||||
/// raising parsed health stats from stderr. The frame producer (compositor →
|
||||
/// capture managers, TASK 4 ship step 5) feeds <see cref="SubmitFrameAsync"/> at
|
||||
/// capture rate; this service serializes writes, parses progress, and tears the
|
||||
/// process down gracefully. Constructor-injected <see cref="IFfmpegLocator"/> +
|
||||
/// process factory keep it hermetic (tests fake both).
|
||||
/// </summary>
|
||||
public interface IFfmpegEncoder : IDisposable
|
||||
{
|
||||
/// <summary>Fires on each parsed ffmpeg <c>-stats</c> progress line (~2 Hz).</summary>
|
||||
event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
|
||||
event EventHandler<string>? ProcessFailed;
|
||||
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an absolute path to a usable <c>ffmpeg.exe</c>, downloading it on
|
||||
/// first use if neither the user's PATH nor the local cache provides one — the
|
||||
/// encoder's one external dependency is never shipped in the repo (TASK 4 ship
|
||||
/// step 2; see TASKS.md). Seam so the encoder step and the tests can fake it.
|
||||
/// </summary>
|
||||
public interface IFfmpegLocator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the path to <c>ffmpeg.exe</c>: the first PATH candidate that
|
||||
/// exists, else the cached copy, else a freshly downloaded one (ffmpeg.exe +
|
||||
/// its libav DLLs extracted from the pinned BtbN LGPL-shared zip into the
|
||||
/// tools directory).
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">The download/extract produced no usable
|
||||
/// binary (offline, expired pin, corrupt archive) — recoverable, logged.</exception>
|
||||
Task<string> LocateAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IGameAudioDetector"/>: samples a full-screen monitor
|
||||
/// provider (the existing <c>IFullScreenDetector</c>) and the live loopback
|
||||
/// level and advances a <see cref="GameAudioHysteresis"/>. The pure transitions
|
||||
/// live in GameAudioHysteresis (unit-tested); this wrapper owns the providers
|
||||
/// and the raised-changed event. WPF-free — the VM owns the poll timer.
|
||||
/// </summary>
|
||||
public sealed class GameAudioDetector : IGameAudioDetector
|
||||
{
|
||||
private const float SoundFloor = 0.005f;
|
||||
|
||||
private readonly Func<int?> _foregroundFullScreenMonitorProvider;
|
||||
private readonly Func<float> _loopbackLevelProvider;
|
||||
private readonly Func<DateTime> _now;
|
||||
private readonly GameAudioHysteresis _hysteresis = new();
|
||||
|
||||
/// <param name="foregroundFullScreenMonitorProvider">Returns the monitor a
|
||||
/// full-screen foreground window covers, or null when windowed/none.</param>
|
||||
/// <param name="loopbackLevelProvider">Current smoothed desktop/game level (0..1).</param>
|
||||
/// <param name="now">Clock for the hysteresis windows; injectable for tests.</param>
|
||||
public GameAudioDetector(
|
||||
Func<int?> foregroundFullScreenMonitorProvider,
|
||||
Func<float> loopbackLevelProvider,
|
||||
Func<DateTime>? now = null)
|
||||
{
|
||||
_foregroundFullScreenMonitorProvider = foregroundFullScreenMonitorProvider;
|
||||
_loopbackLevelProvider = loopbackLevelProvider;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
public bool IsGameAudioActive => _hysteresis.IsActive;
|
||||
|
||||
public event Action<bool>? IsGameAudioActiveChanged;
|
||||
|
||||
public void Poll()
|
||||
{
|
||||
var before = _hysteresis.IsActive;
|
||||
var isFullScreen = _foregroundFullScreenMonitorProvider() != null;
|
||||
var hasSound = _loopbackLevelProvider() > SoundFloor;
|
||||
_hysteresis.Update(isFullScreen, hasSound, _now());
|
||||
if (before != _hysteresis.IsActive)
|
||||
IsGameAudioActiveChanged?.Invoke(_hysteresis.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure show/hide state machine for the game audio bar (TASK 4 game audio).
|
||||
/// SHOW: a full-screen foreground app has been producing desktop audio for at
|
||||
/// least <see cref="ShowAfterSound"/> — "a working game with sound". HIDE: the
|
||||
/// app leaves fullscreen/foreground for <see cref="HideAfterWindowed"/> — the
|
||||
/// game is no longer up in the preview. Silence NEVER hides an active bar; it
|
||||
/// only matters for the initial show. No timers; <see cref="Update"/> is fed by
|
||||
/// the caller with a clock.
|
||||
/// </summary>
|
||||
public sealed class GameAudioHysteresis
|
||||
{
|
||||
private readonly TimeSpan _showAfterSound = TimeSpan.FromMilliseconds(500);
|
||||
private readonly TimeSpan _hideAfterWindowed = TimeSpan.FromSeconds(1);
|
||||
|
||||
private DateTime? _soundSince;
|
||||
private DateTime? _windowedSince;
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public void Update(bool isFullScreen, bool hasSound, DateTime now)
|
||||
{
|
||||
if (isFullScreen)
|
||||
{
|
||||
_windowedSince = null;
|
||||
if (IsActive)
|
||||
return;
|
||||
|
||||
if (!hasSound)
|
||||
{
|
||||
_soundSince = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_soundSince ??= now;
|
||||
if (now - _soundSince >= _showAfterSound)
|
||||
IsActive = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_soundSince = null;
|
||||
if (!IsActive)
|
||||
{
|
||||
_windowedSince = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_windowedSince ??= now;
|
||||
if (now - _windowedSince >= _hideAfterWindowed)
|
||||
{
|
||||
IsActive = false;
|
||||
_windowedSince = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,16 @@ namespace ytLive.Services;
|
||||
public interface ICameraFrameSource
|
||||
{
|
||||
string DeviceId { get; }
|
||||
|
||||
/// <summary>Normalized BGRA frames from a worker thread; callers marshal to the UI thread.</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the capture session fails asynchronously after a successful start
|
||||
/// (device lost, stream state Failed, media capture failure). Carries the reason.
|
||||
/// </summary>
|
||||
event Action<string>? SourceFailed;
|
||||
|
||||
Task StartAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+175
-5
@@ -15,6 +15,9 @@ public class LayoutStore : IDisposable
|
||||
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
||||
public Webcam? Webcam { get; private set; }
|
||||
|
||||
/// <summary>The app-wide social bar config loaded with the last Load() (null = never defined).</summary>
|
||||
public SocialsConfig? Socials { get; private set; }
|
||||
|
||||
public LayoutStore(string path)
|
||||
{
|
||||
ActivePath = path;
|
||||
@@ -104,6 +107,23 @@ public class LayoutStore : IDisposable
|
||||
PRIMARY KEY (SceneId, WebcamId)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Socials (
|
||||
Id TEXT PRIMARY KEY,
|
||||
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
||||
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS SocialEntry (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
||||
Service TEXT NOT NULL,
|
||||
Handle TEXT NOT NULL,
|
||||
ProfileUrl TEXT NOT NULL,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
@@ -114,11 +134,14 @@ public class LayoutStore : IDisposable
|
||||
MigrateSourceTable();
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 6;";
|
||||
cmd.CommandText = "PRAGMA user_version = 8;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -319,16 +342,77 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
||||
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
||||
private void MigrateSceneSocialBarColumn()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasSocialBar")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
||||
// BarJustify column stays for back-compat but is no longer read or written.
|
||||
private void MigrateSocialsTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Socials);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("BarEnabled")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||
// software name) so a reloaded entry still shows the right service logo.
|
||||
private void MigrateSocialEntryTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("Software")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
Socials = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop FROM Scene ORDER BY SortOrder";
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -339,6 +423,7 @@ public class LayoutStore : IDisposable
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
HasBackdrop = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -358,6 +443,36 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||
};
|
||||
var socialsId = reader.GetString(0);
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||
using var entryReader = entryCmd.ExecuteReader();
|
||||
while (entryReader.Read())
|
||||
{
|
||||
Socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||
Handle = entryReader.GetString(1),
|
||||
ProfileUrl = entryReader.GetString(2),
|
||||
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
@@ -444,7 +559,7 @@ public class LayoutStore : IDisposable
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam)
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
@@ -466,6 +581,18 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM SocialEntry;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Socials;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
@@ -475,8 +602,8 @@ public class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -484,6 +611,7 @@ public class LayoutStore : IDisposable
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
||||
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
@@ -494,6 +622,7 @@ public class LayoutStore : IDisposable
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -626,6 +755,47 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
if (socials != null && socials.Entries.Count > 0)
|
||||
{
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = """
|
||||
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
|
||||
""";
|
||||
entryCmd.Transaction = tx;
|
||||
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
|
||||
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
|
||||
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var entrySort = 0;
|
||||
foreach (var entry in socials.Entries)
|
||||
{
|
||||
eId.Value = Guid.NewGuid().ToString();
|
||||
eSocialsId.Value = socialsId;
|
||||
eService.Value = entry.Service.ToString();
|
||||
eHandle.Value = entry.Handle;
|
||||
eUrl.Value = entry.ProfileUrl;
|
||||
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
|
||||
eSort.Value = entrySort++;
|
||||
entryCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
@@ -6,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Media.Capture;
|
||||
using Windows.Media.Capture.Frames;
|
||||
using Windows.Media.Devices;
|
||||
using Windows.Media.MediaProperties;
|
||||
using ytLive.Helpers;
|
||||
|
||||
@@ -16,17 +18,28 @@ namespace ytLive.Services;
|
||||
/// preview source; the capture pipeline does any format conversion, so every
|
||||
/// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a
|
||||
/// worker thread — marshal before touching WPF.
|
||||
///
|
||||
/// Allocation is validated, never taken on faith: after <c>InitializeAsync</c>
|
||||
/// the bound device, its frame sources, and its stream properties are checked,
|
||||
/// the frame reader's start status is read (not swallowed), and the live
|
||||
/// state signals (<c>Failed</c>, <c>CameraStreamStateChanged</c>) are subscribed
|
||||
/// so an async death surfaces as <see cref="SourceFailed"/> instead of a silent
|
||||
/// empty preview.
|
||||
/// </summary>
|
||||
public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
{
|
||||
private readonly string _deviceId;
|
||||
private MediaCapture? _capture;
|
||||
private MediaFrameReader? _frameReader;
|
||||
private string? _lastError;
|
||||
private bool _isFailed;
|
||||
|
||||
public string DeviceId => _deviceId;
|
||||
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
public event Action<string>? SourceFailed;
|
||||
|
||||
public MediaCaptureFrameSource(string deviceId)
|
||||
{
|
||||
_deviceId = deviceId;
|
||||
@@ -34,47 +47,15 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var capture = new MediaCapture();
|
||||
MediaFrameReader? reader = null;
|
||||
try
|
||||
// Fallback ladder: prefer the camera's VideoPreview stream, but if the
|
||||
// reader refuses to start there (NoVideoFrameAvailable), retry against
|
||||
// its VideoRecord stream — some devices only deliver through it.
|
||||
// (MediaCaptureSharingMode.Exclusive is not projected by the 19041 SDK.)
|
||||
if (!await TryStartAsync(preferVideoRecord: false))
|
||||
{
|
||||
var settings = new MediaCaptureInitializationSettings
|
||||
{
|
||||
VideoDeviceId = _deviceId,
|
||||
StreamingCaptureMode = StreamingCaptureMode.Video,
|
||||
MemoryPreference = MediaCaptureMemoryPreference.Cpu,
|
||||
SharingMode = MediaCaptureSharingMode.SharedReadOnly,
|
||||
};
|
||||
await capture.InitializeAsync(settings);
|
||||
|
||||
var colorSource = capture.FrameSources.Values
|
||||
.OrderBy(s => s.Info.MediaStreamType == MediaStreamType.VideoPreview ? 0 : 1)
|
||||
.FirstOrDefault(s => s.Info.MediaStreamType is MediaStreamType.VideoPreview or MediaStreamType.VideoRecord);
|
||||
if (colorSource == null)
|
||||
{
|
||||
var kinds = string.Join(", ", capture.FrameSources.Values
|
||||
.Select(s => s.Info.MediaStreamType).Distinct());
|
||||
if (!await TryStartAsync(preferVideoRecord: true))
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' exposes no video source (found: {kinds}). " +
|
||||
"It may be locked by another app (e.g. NVIDIA Broadcast).");
|
||||
}
|
||||
|
||||
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
|
||||
reader.FrameArrived += OnFrameArrived;
|
||||
await reader.StartAsync();
|
||||
|
||||
_capture = capture;
|
||||
_frameReader = reader;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (reader != null)
|
||||
{
|
||||
reader.FrameArrived -= OnFrameArrived;
|
||||
reader.Dispose();
|
||||
}
|
||||
capture.Dispose();
|
||||
throw;
|
||||
_lastError ?? $"Camera '{_deviceId}' could not be started.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +79,134 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
|
||||
var capture = _capture;
|
||||
_capture = null;
|
||||
capture?.Dispose();
|
||||
if (capture != null)
|
||||
{
|
||||
UnsubscribeCaptureEvents(capture);
|
||||
capture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryStartAsync(bool preferVideoRecord)
|
||||
{
|
||||
MediaCapture? capture = null;
|
||||
MediaFrameReader? reader = null;
|
||||
try
|
||||
{
|
||||
capture = new MediaCapture();
|
||||
var settings = new MediaCaptureInitializationSettings
|
||||
{
|
||||
VideoDeviceId = _deviceId,
|
||||
StreamingCaptureMode = StreamingCaptureMode.Video,
|
||||
MemoryPreference = MediaCaptureMemoryPreference.Cpu,
|
||||
SharingMode = MediaCaptureSharingMode.SharedReadOnly,
|
||||
};
|
||||
await capture.InitializeAsync(settings);
|
||||
|
||||
// Post-init resource validation: the capture must actually be bound to
|
||||
// the device we asked for and must expose a live video controller.
|
||||
if (!string.Equals(capture.MediaCaptureSettings?.VideoDeviceId, _deviceId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' initialized but bound a different device.");
|
||||
|
||||
var colorSource = capture.FrameSources.Values
|
||||
.OrderBy(s => preferVideoRecord
|
||||
? (s.Info.MediaStreamType == MediaStreamType.VideoRecord ? 0 : 1)
|
||||
: (s.Info.MediaStreamType == MediaStreamType.VideoPreview ? 0 : 1))
|
||||
.FirstOrDefault(s => s.Info.MediaStreamType is MediaStreamType.VideoPreview or MediaStreamType.VideoRecord);
|
||||
if (colorSource == null)
|
||||
{
|
||||
var kinds = string.Join(", ", capture.FrameSources.Values
|
||||
.Select(s => s.Info.MediaStreamType).Distinct());
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' exposes no video source (found: {kinds}). " +
|
||||
"It may be locked by another app (e.g. NVIDIA Broadcast).");
|
||||
}
|
||||
|
||||
// The device must answer for its preview stream — a dead, suspended, or
|
||||
// locked device returns no stream properties even after "successful" init.
|
||||
IReadOnlyList<IMediaEncodingProperties>? previewProps;
|
||||
try
|
||||
{
|
||||
previewProps = capture.VideoDeviceController?
|
||||
.GetAvailableMediaStreamProperties(MediaStreamType.VideoPreview);
|
||||
}
|
||||
catch
|
||||
{
|
||||
previewProps = null;
|
||||
}
|
||||
if (previewProps == null || previewProps.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' answered no video stream properties (offline, suspended, or locked).");
|
||||
|
||||
// The OS's live state signals: async failures must surface, not vanish.
|
||||
capture.Failed += OnCaptureFailed;
|
||||
capture.CameraStreamStateChanged += OnCameraStreamStateChanged;
|
||||
|
||||
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
|
||||
if (reader == null)
|
||||
throw new InvalidOperationException($"Camera '{_deviceId}' created no frame reader.");
|
||||
|
||||
reader.FrameArrived += OnFrameArrived;
|
||||
var status = await reader.StartAsync();
|
||||
if (status != MediaFrameReaderStartStatus.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' frame reader refused to start: {status}.");
|
||||
|
||||
_capture = capture;
|
||||
_frameReader = reader;
|
||||
_lastError = null;
|
||||
_isFailed = false;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lastError = ex.Message;
|
||||
if (reader != null)
|
||||
{
|
||||
reader.FrameArrived -= OnFrameArrived;
|
||||
reader.Dispose();
|
||||
}
|
||||
if (capture != null)
|
||||
{
|
||||
UnsubscribeCaptureEvents(capture);
|
||||
capture.Dispose();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeCaptureEvents(MediaCapture capture)
|
||||
{
|
||||
capture.Failed -= OnCaptureFailed;
|
||||
capture.CameraStreamStateChanged -= OnCameraStreamStateChanged;
|
||||
}
|
||||
|
||||
private void OnCaptureFailed(MediaCapture sender, MediaCaptureFailedEventArgs args)
|
||||
=> RaiseFailure($"capture failed ({args.Code}): {args.Message}");
|
||||
|
||||
private void OnCameraStreamStateChanged(MediaCapture sender, object args)
|
||||
{
|
||||
// CameraStreamState enum (Windows.Media.Devices): 0=NotStreaming, 1=Streaming,
|
||||
// 2=Failed, 3=Shutdown. The 19041 SDK projection omits member names, so
|
||||
// compare by value — the enum type itself resolves via the property return.
|
||||
if ((int)sender.CameraStreamState == 2) // Failed
|
||||
RaiseFailure("camera stream state is Failed");
|
||||
}
|
||||
|
||||
private void RaiseFailure(string message)
|
||||
{
|
||||
if (_isFailed) return;
|
||||
_isFailed = true;
|
||||
AppLog.Write($"MediaCaptureFrameSource: {message}");
|
||||
try
|
||||
{
|
||||
SourceFailed?.Invoke(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"MediaCaptureFrameSource: SourceFailed handler threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
|
||||
|
||||
@@ -156,6 +156,15 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>The most recent frame for a capture key, or null before the first
|
||||
/// frame arrives (or if the key has no session). The live compositor reads
|
||||
/// the backdrop from here.</summary>
|
||||
public VideoFrame? GetLatestFrame(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<CaptureSession> sessions;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public sealed class SocialLookupResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string Error { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
|
||||
public string? FediverseSoftware { get; init; }
|
||||
|
||||
/// <summary>True when the caller canceled the lookup before it finished.</summary>
|
||||
public bool Canceled { get; init; }
|
||||
}
|
||||
|
||||
public interface ISocialValidator
|
||||
{
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, 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>
|
||||
/// Validates a social handle/URL by constructing the canonical profile URL and
|
||||
/// issuing an HTTP GET — the "act of validation" the creator trusts before the
|
||||
/// icon lands on their bar. Best-effort: some platforms return challenges/blocks
|
||||
/// to HEAD requests, so a 200 OR a 301/302 redirect counts as "exists"; 404 or
|
||||
/// connection failure = rejected. Never blind trust.
|
||||
/// </summary>
|
||||
public sealed class HttpSocialValidator : ISocialValidator
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly Action<string>? _log;
|
||||
|
||||
public HttpSocialValidator(HttpClient? client = null, Action<string>? log = null)
|
||||
{
|
||||
_client = client ?? new HttpClient();
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
|
||||
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
var input = handleOrUrl.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return new SocialLookupResult { Error = "Enter a handle or URL." };
|
||||
|
||||
// Fediverse handle (@user@domain): the domain is part of the identity,
|
||||
// so the canonical URL can't be rebuilt from a bare handle. After the
|
||||
// profile validates, ask the instance which software it runs (nodeinfo)
|
||||
// so the caller can show the right service logo.
|
||||
if (input.StartsWith('@') && input.IndexOf('@', 1) > 0)
|
||||
{
|
||||
var at = input.IndexOf('@', 1);
|
||||
var user = input[1..at];
|
||||
var domain = input[(at + 1)..];
|
||||
if (user.Length > 0 && domain.Length > 0)
|
||||
{
|
||||
var url = $"https://{domain}/@{user}";
|
||||
var result = await CheckAsync(service, input, url, ct);
|
||||
if (!result.Success) return result;
|
||||
var software = await TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = result.ProfileUrl,
|
||||
Handle = result.Handle,
|
||||
FediverseSoftware = software,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var handle = input;
|
||||
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = new System.Uri(input);
|
||||
handle = uri.AbsolutePath.Trim('/');
|
||||
}
|
||||
|
||||
var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||
return await CheckAsync(service, handle, canonical, ct);
|
||||
}
|
||||
|
||||
private async Task<SocialLookupResult> CheckAsync(SocialService service, string handle, string url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await _client.GetAsync(url, ct);
|
||||
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = url,
|
||||
Handle = handle,
|
||||
};
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
|
||||
};
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
}
|
||||
catch (System.Net.Http.HttpRequestException)
|
||||
{
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Error = $"Couldn't reach {service} to validate '{handle}'. Check your connection and try again.",
|
||||
};
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return new SocialLookupResult { Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first
|
||||
/// nodeinfo link, read `software.name`. If the identity domain is itself a
|
||||
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
|
||||
/// the bare root 302s to the real instance — follow it and ask that host.
|
||||
/// If neither answers, probe well-known subdomains (mastodon.example.com)
|
||||
/// so a landing-page domain still resolves its real instance. The whole
|
||||
/// resolution is bounded by a ~15s budget. Failures return null — validation
|
||||
/// still succeeds, the icon just falls back to the generic fediverse glyph.
|
||||
/// </summary>
|
||||
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
budget.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var software = await FetchSoftwareNameAsync(domain, budget.Token);
|
||||
if (software != null) return software;
|
||||
|
||||
var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
|
||||
if (resolved != null
|
||||
&& !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
software = await FetchSoftwareNameAsync(resolved, budget.Token);
|
||||
if (software != null) return software;
|
||||
}
|
||||
|
||||
foreach (var sub in FediverseSubdomainCandidates)
|
||||
{
|
||||
var host = $"{sub}.{domain}";
|
||||
software = await FetchSoftwareNameAsync(host, budget.Token);
|
||||
if (software != null)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}");
|
||||
return software;
|
||||
}
|
||||
}
|
||||
_log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'");
|
||||
return null;
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return null; // caller checks ct.IsCancellationRequested and reports Canceled
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out");
|
||||
return null;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Common instance subdomains, probed in order when the identity
|
||||
/// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front
|
||||
/// of a self-hosted instance).</summary>
|
||||
private static readonly string[] FediverseSubdomainCandidates =
|
||||
{
|
||||
"mastodon", "social", "fediverse", "tube", "peertube",
|
||||
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
|
||||
};
|
||||
|
||||
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
|
||||
if (nodeInfoUrl == null) return null;
|
||||
using var resp = await _client.GetAsync(nodeInfoUrl, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("software", out var software)
|
||||
&& software.TryGetProperty("name", out var name))
|
||||
return name.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
|
||||
/// and returns the final host — the "default app" behind an identity domain.</summary>
|
||||
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/", ct);
|
||||
return resp.RequestMessage?.RequestUri?.Host;
|
||||
}
|
||||
|
||||
private async Task<string?> FetchNodeInfoUrlAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("links", out var links)) return null;
|
||||
foreach (var link in links.EnumerateArray())
|
||||
{
|
||||
if (!link.TryGetProperty("rel", out var rel)) continue;
|
||||
var relStr = rel.GetString() ?? string.Empty;
|
||||
if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue;
|
||||
if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed))
|
||||
return parsed.IsAbsoluteUri
|
||||
? parsed.AbsoluteUri
|
||||
: new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -12,13 +12,13 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
||||
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
|
||||
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` + **`SourceFailed(string)`** — seam for a running capture source (tests inject fakes) |
|
||||
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
|
||||
| `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device |
|
||||
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) |
|
||||
| `WinRtMicrophoneEnumerator.cs` | WinRT mic enumeration via `DeviceInformation.FindAllAsync(DeviceClass.AudioCapture)` (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
|
||||
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
|
||||
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
|
||||
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`). **Validated, not trusted:** post-init checks (`VideoDeviceId` match, `FrameSources` non-empty, `VideoDeviceController.GetAvailableMediaStreamProperties` ≥ 1); `reader.StartAsync()` status read (throws on non-`Success`); `capture.Failed` + `CameraStreamStateChanged` subscribed → `SourceFailed` event. Fallback ladder: VideoPreview → VideoRecord retry. `SharingMode.Exclusive` + `DeviceLost` not in 19041 SDK projection; `CameraStreamState.Failed` compared by `(int)2` (projection omits member names) |
|
||||
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. **First-frame proof** (4s timeout, configurable via ctor `TimeSpan?`): `AcquireAsync` returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces `CameraFailed` with suspect-app names (`CameraConflictProbe`) instead of a silent empty box. `SourceFailed` forwarded from the frame source as async death signal |
|
||||
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
|
||||
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
|
||||
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
|
||||
@@ -27,6 +27,33 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
|
||||
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
|
||||
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
|
||||
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
|
||||
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
|
||||
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
|
||||
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
|
||||
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
|
||||
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead |
|
||||
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
|
||||
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
|
||||
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
|
||||
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
|
||||
|
||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<Window x:Class="ytLive.SocialsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Social Media Site Promotion" Height="520" Width="520"
|
||||
Icon="/Assets/llama-logo-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,12"/>
|
||||
|
||||
<!-- Show/hide the bar on the stream -->
|
||||
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
|
||||
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
|
||||
</CheckBox>
|
||||
|
||||
<!-- Sign-in gate: the first slot is always your YouTube channel -->
|
||||
<Border Grid.Row="2" Background="#16213e" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
|
||||
Visibility="{Binding ShowSignInBanner, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Sign in to YouTube first — the first slot is your channel."
|
||||
Foreground="#e0e0e0" FontSize="12" TextWrapping="Wrap"/>
|
||||
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Left"
|
||||
Margin="0,8,0,0" Padding="12,4" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Grid.Row="2" Text="Signing in… open the browser to authorize, then come back here."
|
||||
Foreground="#e94560" FontSize="12" Margin="0,0,0,10"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- The six fixed slots -->
|
||||
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Slots}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="34"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Service icon: logo, lock (Premium) or do-not (empty/invalid) -->
|
||||
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
|
||||
Background="#16213e" HorizontalAlignment="Left" VerticalAlignment="Top">
|
||||
<Grid>
|
||||
<Path Data="{Binding LogoData}" Fill="White" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowLogo, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Data="{Binding LockedIconData}" Fill="#8a93a8" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsLocked, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Data="{Binding DoNotIconData}" Fill="#5a5a6e" Stretch="Uniform"
|
||||
Width="15" Height="15" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowDoNot, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Handle text / edit box / status -->
|
||||
<Grid Grid.Column="1" Margin="10,0,8,0">
|
||||
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0" FontSize="14"
|
||||
VerticalAlignment="Top" Margin="0,6,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{Binding ShowDisplay, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBox Text="{Binding EditText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBox}" FontSize="13"
|
||||
KeyDown="SocialEdit_KeyDown" LostFocus="SocialEdit_LostFocus"
|
||||
Visibility="{Binding ShowEditBox, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Error}" Foreground="#e94560" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,32,0,0"
|
||||
Visibility="{Binding ShowError, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="Checking…" Foreground="#a0a0b0" FontSize="11" Margin="0,32,0,0"
|
||||
Visibility="{Binding ShowBusy, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Row actions -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Top"
|
||||
Margin="0,3,0,0">
|
||||
<Button Content="Sign in" Click="SignInButton_Click"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,3"
|
||||
FontSize="11" Margin="0,0,6,0"
|
||||
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="+" Click="SlotAdd_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Add a social" FontSize="16" FontWeight="Bold"
|
||||
Visibility="{Binding ShowAddButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="✏️" Click="SlotEdit_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Edit" FontSize="13"
|
||||
Visibility="{Binding ShowEditButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="🗑️" Click="SlotDelete_Click" Tag="{Binding Index}"
|
||||
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||
ToolTip="Remove" FontSize="12" Foreground="#e94560"
|
||||
Visibility="{Binding ShowDeleteButton, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" IsCancel="True"/>
|
||||
<Button Content="Save" Command="{Binding SaveCommand}" Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class SocialsDialog : Window
|
||||
{
|
||||
private readonly SocialsDialogViewModel _viewModel;
|
||||
|
||||
public SocialsDialog(SocialsDialogViewModel viewModel)
|
||||
{
|
||||
AppLog.Write("SocialsDialog ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
AppLog.Write("SocialsDialog ctor: after InitializeComponent");
|
||||
DataContext = viewModel;
|
||||
_viewModel = viewModel;
|
||||
viewModel.SaveRequested += () => DialogResult = true;
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
Closing += (_, _) => _viewModel.AbortPending();
|
||||
}
|
||||
|
||||
private void SignInButton_Click(object sender, RoutedEventArgs e)
|
||||
=> _viewModel.SignInCommand.Execute(null);
|
||||
|
||||
private void SlotAdd_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: int index })
|
||||
_viewModel.StartEdit(index);
|
||||
}
|
||||
|
||||
private void SlotEdit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: int index })
|
||||
_viewModel.StartEdit(index);
|
||||
}
|
||||
|
||||
private async void SlotDelete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: int index } element) return;
|
||||
var slot = element.DataContext as SocialSlotViewModel;
|
||||
var confirm = index == 0
|
||||
? "Remove your YouTube channel from the bar and sign out of ytLlive?"
|
||||
: slot is { IsFilled: true }
|
||||
? $"Remove {slot.Handle}?"
|
||||
: "Remove this social?";
|
||||
|
||||
if (MessageBox.Show(confirm, "Remove Social", MessageBoxButton.YesNo, MessageBoxImage.Question)
|
||||
== MessageBoxResult.Yes)
|
||||
await _viewModel.DeleteSlotAsync(index);
|
||||
}
|
||||
|
||||
private void SocialEdit_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { DataContext: SocialSlotViewModel slot }) return;
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
_viewModel.ConfirmEdit(slot.Index);
|
||||
}
|
||||
else if (e.Key == Key.Escape)
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
slot.Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialEdit_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SocialSlotViewModel slot })
|
||||
_viewModel.ConfirmEdit(slot.Index);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,20 @@
|
||||
> Task queue and authoritative research. Memory-map conventions: [`schema.md`](schema.md);
|
||||
> architecture/decisions: [`ai.md`](ai.md). Update statuses here whenever a task moves.
|
||||
|
||||
> **Checklist markers** — every task's Status list uses the same states:
|
||||
> 1. ✅ — completed (green check)
|
||||
> 2. ☐ — not completed / pending (empty box)
|
||||
> 3. ❌ — exception (blocked, known-issue, or deliberately excluded from this build)
|
||||
|
||||
## YouTube Live API — research facts (authoritative, v3 build)
|
||||
|
||||
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||
|
||||
- **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||
- **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||
- **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||
- **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||
- **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||
1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||
2. **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||
3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||
4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||
5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||
|
||||
### Two features that reshape the design
|
||||
|
||||
@@ -20,17 +25,17 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
|
||||
### Compliance gotchas (maps perfectly to report-by-exception)
|
||||
|
||||
- `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||
- Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||
- Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||
1. `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||
2. Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||
3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||
|
||||
### Tips we should take advantage of
|
||||
|
||||
- **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||
- **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||
- **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||
- **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||
- **Broadcast ID == Video ID** — one ID to track everything.
|
||||
1. **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||
2. **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||
3. **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||
4. **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||
5. **Broadcast ID == Video ID** — one ID to track everything.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,11 +44,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||
|
||||
### Status: ✅ Done
|
||||
- Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||
- Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||
- MainViewModel: scene management, stream controls, chat
|
||||
- MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||
- Clean build, 0 warnings (WSL + Windows)
|
||||
|
||||
1. ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||
2. ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||
3. ✅ MainViewModel: scene management, stream controls, chat
|
||||
4. ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||
5. ✅ Clean build, 0 warnings (WSL + Windows)
|
||||
|
||||
---
|
||||
|
||||
@@ -51,6 +57,15 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
|
||||
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||
2. ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
||||
3. ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
|
||||
4. ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
|
||||
5. ✅ **End Livestream signs out** — a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast
|
||||
6. ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing
|
||||
|
||||
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
|
||||
|
||||
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
|
||||
@@ -68,17 +83,9 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
|
||||
### Tests:
|
||||
|
||||
- Mock token exchange response, verify channel info parsed
|
||||
- Verify token refresh triggers when near expiry
|
||||
- Verify credential load/save roundtrip
|
||||
|
||||
### Status: ✅ Complete
|
||||
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||
- ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
||||
- ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
|
||||
- ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
|
||||
- ✅ **End Livestream signs out** — a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast
|
||||
- ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing
|
||||
1. Mock token exchange response, verify channel info parsed
|
||||
2. Verify token refresh triggers when near expiry
|
||||
3. Verify credential load/save roundtrip
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +93,31 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
|
||||
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
||||
|
||||
### Status: 🔶 In progress
|
||||
|
||||
1. ✅ **Milestone 1 — webcam** — MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480×270 default placement — schema v2
|
||||
2. ✅ **Schema v3 (Ship Branch A)** — multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) — 25 tests passing
|
||||
3. ✅ **Schema v4** — round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load
|
||||
4. ✅ **Screen backdrop (ship task #1, schema v5)** — live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing
|
||||
5. ✅ **Schema v6 — backdrop Live-only by policy** — `Scene.HasBackdrop`, enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix
|
||||
6. ✅ **The five-scene catalog (`SceneCatalog`)** — Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones) — 62 tests passing
|
||||
7. ✅ **Webcam-after-session-start fix** — a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 65 tests passing
|
||||
8. ✅ **Chat scene webcam size cap** — raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better
|
||||
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
||||
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
||||
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
|
||||
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
||||
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||
16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
|
||||
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
||||
18. ☐ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video
|
||||
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
|
||||
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
|
||||
21. ☐ **Logo + richer in-app About (2026-08-13, queued)** — the ytLlive wordmark in the top bar is clickable and opens the About overlay; make a real **logo** (CC0 per the icon guardrail), and expand the About overlay into the creator's hub: **links to premium unlock + coffee/thanks buys + socials** and a second (or third) **scrolling panel with the full licensing/legal texts on demand** — everything stays IN-APP (no Notepad, no browser tab; the app is standalone). The top-bar About button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer was removed on 2026-08-13 for exactly this reason
|
||||
22. ☐ **Voice filters on the mic channel (2026-08-13, queued — KISS, always on)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Always-on — no UI knobs; the mic stays the creator's single audio control
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
||||
@@ -107,17 +139,17 @@ Deliberately NOT supported: game capture, browser source, media playlist, VLC, c
|
||||
|
||||
### Source memory model (design decision)
|
||||
|
||||
- A scene has resources. Resources can be shared across scenes.
|
||||
- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||
scenes = one loaded bitmap).
|
||||
- Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
||||
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
||||
scale mode, crop.
|
||||
- Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
||||
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
||||
- A webcam in two scenes = one capture session, two catalog entries.
|
||||
- Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||
- The resource (not a per-scene node) owns everything `IDisposable`.
|
||||
1. A scene has resources. Resources can be shared across scenes.
|
||||
2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||
scenes = one loaded bitmap).
|
||||
3. Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
||||
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
||||
scale mode, crop.
|
||||
4. Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
||||
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
||||
5. A webcam in two scenes = one capture session, two catalog entries.
|
||||
6. Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||
7. The resource (not a per-scene node) owns everything `IDisposable`.
|
||||
|
||||
### Scene transitions (design decision)
|
||||
|
||||
@@ -137,14 +169,14 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
|
||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||
2. **Webcam** — MediaCapture (WinRT SDK projection) with device enumeration — ✅ **milestone 1 done**:
|
||||
- TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
|
||||
- `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
|
||||
- `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
|
||||
- `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
|
||||
- One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
|
||||
- Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
|
||||
- **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
|
||||
- **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
|
||||
1. TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
|
||||
2. `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
|
||||
3. `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
|
||||
4. `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
|
||||
5. One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
|
||||
6. Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
|
||||
7. **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
|
||||
8. **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
|
||||
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
||||
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
||||
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
||||
@@ -155,11 +187,9 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
`BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer,
|
||||
and v0.2 local recordings carry it too
|
||||
7. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
||||
- **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||
- **Scenes list:** drag rows to reorder scenes
|
||||
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"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…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; **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); 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); window capture, compositing, encoding pending
|
||||
1. **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||
2. **Scenes list:** drag rows to reorder scenes
|
||||
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
---
|
||||
|
||||
@@ -167,17 +197,33 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
|
||||
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
||||
|
||||
### Status: 🔶 In progress
|
||||
|
||||
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
|
||||
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
|
||||
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
|
||||
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
|
||||
5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
|
||||
6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
|
||||
7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
|
||||
|
||||
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
|
||||
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
|
||||
→ **vertical-tier crop/scale** → **RTMP push** → **health stats** into the bottom bar. Nothing can encode
|
||||
until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||
`CameraManager + ScreenCaptureManager → compositor resolver → compositor → encoder → RTMP`.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
|
||||
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
|
||||
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + reconnect. **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
|
||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||
- 720p30 @ 6 Mbps
|
||||
- 720p60 @ 6 Mbps
|
||||
- 1080p30 @ 8 Mbps
|
||||
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||
machine never notices; upload headroom stays comfortable)
|
||||
- Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
|
||||
1. 720p30 @ 6 Mbps
|
||||
2. 720p60 @ 6 Mbps
|
||||
3. 1080p30 @ 8 Mbps
|
||||
4. **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||
machine never notices; upload headroom stays comfortable)
|
||||
5. Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
|
||||
The composition master is always 1920×1080; a tier is an output rect + target resolution
|
||||
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
|
||||
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
|
||||
@@ -191,8 +237,292 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
|
||||
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
|
||||
6. **One-click go live** — defaults that work out of the box
|
||||
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
|
||||
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
|
||||
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)** — `THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
|
||||
|
||||
### Status: Not started
|
||||
#### Ship step 1 — Scene compositor (the frame source)
|
||||
|
||||
**Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame`
|
||||
seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output
|
||||
view** — WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers
|
||||
must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract.
|
||||
|
||||
**Decisions (locked 2026-08-10):** **Path A CPU blitter** — GPU effort belongs to NVENC (the encoder),
|
||||
not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame
|
||||
anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers
|
||||
pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU
|
||||
master buffer stays the contract). **Render the output rect directly**: compositor is constructed with
|
||||
`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920×1080 1:1;
|
||||
vertical (9:16) = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. Reuses
|
||||
`MainViewModel.OutputRectX/Y/W/H` (note `(1920−607)/2 = 656.5` → align to integer pixels for output).
|
||||
|
||||
**Render spec (back → front, mirror the XAML exactly):**
|
||||
1. Backdrop — the Live scene's `IsBackdrop` Source (`CaptureKey` → live frame), `UniformToFill`
|
||||
full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing — its
|
||||
DataTemplate Image is Collapsed for DisplayCapture).
|
||||
2. Background — the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage`
|
||||
layer, not per-element).
|
||||
3. Elements in `Scene.Elements` order (back→front), skip `IsVisible=false`. What actually renders:
|
||||
1. `Source` Type `Image` → static asset, `UniformToFill` cover-crop into (X, Y, W, H)
|
||||
2. `WebcamSceneConfig` → latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle
|
||||
diameter `min(W,H)` (alpha 0 outside — true circle, not oval); mirror = horizontal flip around
|
||||
element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked
|
||||
rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity`
|
||||
3. `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped)
|
||||
4. Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
|
||||
`BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math,
|
||||
no WPF; likely a bundled asset rather than runtime text rendering).
|
||||
5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
|
||||
|
||||
**New files (all in `Services/Compositor/`):**
|
||||
1. `SceneCompositor.cs` — `Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame:
|
||||
VideoFrame?, CompositorOptions) → VideoFrame` (output-sized). The caller's `frameFor` resolver maps
|
||||
each element to its frame (webcam → DeviceId, image → AssetId via `StaticPixelCache`, backdrop →
|
||||
CaptureKey) — the compositor stays pure/hermetic/no WPF.
|
||||
2. `CompositorOptions.cs` — source-rect + output W×H.
|
||||
3. `StretchMath.cs` — `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested).
|
||||
4. `StaticPixelCache.cs` — asset `byte[]` → cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`,
|
||||
decode once per content hash).
|
||||
|
||||
**Test plan (Good Dog Rule — ONE integration test):** `SceneCompositorTests` — a scene with backdrop
|
||||
(solid red fake frame) + round webcam (solid green) + image (solid blue) → render 16:9 master → assert
|
||||
per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip =
|
||||
backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080×1920 output +
|
||||
crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly — no capture managers
|
||||
involved (they wire in a later step).
|
||||
|
||||
**Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2"
|
||||
→ "shown left-justified INSIDE the meter bar" — `ai.md` is the authority); this task's requirements now
|
||||
include the explicit audio-capture/meter wiring (#7 above).
|
||||
|
||||
**Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2),
|
||||
encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring
|
||||
`CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats
|
||||
(bitrate/FPS/dropped).
|
||||
|
||||
**Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor`
|
||||
made public (shared hex parse with the compositor — no duplicated color parsing), the stale
|
||||
`MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests`
|
||||
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
|
||||
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
|
||||
|
||||
#### Ship step 2 — FFmpeg locator (the encoder's binary)
|
||||
|
||||
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
|
||||
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
|
||||
provides one.
|
||||
|
||||
**Decisions (locked 2026-08-10):**
|
||||
1. **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
|
||||
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
|
||||
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
|
||||
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
|
||||
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
|
||||
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
|
||||
2. **Pinned URL** — `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip`
|
||||
(~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
|
||||
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
|
||||
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once
|
||||
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
|
||||
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
|
||||
3. **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
|
||||
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
|
||||
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
|
||||
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
|
||||
4. **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
|
||||
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
|
||||
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
|
||||
|
||||
**New files (all in `Services/Encoder/`):**
|
||||
1. `IFfmpegLocator.cs` — the seam.
|
||||
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
|
||||
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
|
||||
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
|
||||
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
|
||||
|
||||
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
|
||||
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
|
||||
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
|
||||
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
|
||||
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
|
||||
throws, downloader failure propagates, zero-byte cache is refreshed.
|
||||
|
||||
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
|
||||
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
|
||||
`ai.md` so the reasoning survives.
|
||||
|
||||
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
|
||||
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
|
||||
|
||||
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
|
||||
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
|
||||
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
|
||||
guardrails are recorded in `ai.md` — build **0 warnings**.
|
||||
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
|
||||
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
|
||||
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
|
||||
|
||||
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
|
||||
|
||||
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
|
||||
ingestion URL — one battle-tested subprocess doing encode + mux + push + reconnect, the app feeding
|
||||
frames via stdin and parsing stderr for health (req 2).
|
||||
|
||||
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
|
||||
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
|
||||
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
|
||||
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
|
||||
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
|
||||
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
|
||||
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
|
||||
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
|
||||
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
|
||||
whole subprocess (probe + encoder), no real binary.
|
||||
|
||||
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
|
||||
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
|
||||
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
|
||||
on a non-zero unexpected exit.
|
||||
|
||||
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
|
||||
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
|
||||
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
|
||||
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
|
||||
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
|
||||
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
|
||||
|
||||
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
|
||||
|
||||
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
|
||||
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
|
||||
|
||||
**Decisions (locked):**
|
||||
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
|
||||
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
|
||||
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
|
||||
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
|
||||
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
|
||||
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
|
||||
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
|
||||
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
|
||||
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
|
||||
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
|
||||
`StopStream` → `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
|
||||
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
|
||||
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
|
||||
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
|
||||
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
|
||||
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
|
||||
GUID. Trailing partial samples are ignored.
|
||||
|
||||
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
|
||||
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
|
||||
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
|
||||
`MicLevelChanged` → `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
|
||||
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
|
||||
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
|
||||
extensible/truncation) — **139 passing**.
|
||||
|
||||
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
|
||||
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
|
||||
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
|
||||
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
|
||||
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
|
||||
|
||||
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
|
||||
|
||||
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
|
||||
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
|
||||
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
|
||||
|
||||
**Decisions (locked via user Q&A, 2026-08-12):**
|
||||
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
|
||||
WASAPI samples into the encoder's AAC track is its own later step.
|
||||
2. **RTMP URL via a provider seam** — `MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
|
||||
null today (the reusable stream's ingest URL lands with TASK 5); when it yields null the pump logs
|
||||
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
|
||||
|
||||
**Design:**
|
||||
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
|
||||
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
|
||||
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
|
||||
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
|
||||
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
|
||||
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
|
||||
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
|
||||
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
|
||||
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
|
||||
backdrop's live frame for the compositor.
|
||||
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig` → `GetLatestFrame(WebcamId)`;
|
||||
`Source.IsLiveCapture` → `GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
|
||||
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
|
||||
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
|
||||
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
|
||||
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
|
||||
the pump fails while live (minimal — detailed health surfacing is ship step 6).
|
||||
|
||||
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
|
||||
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
|
||||
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
|
||||
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
|
||||
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
|
||||
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
|
||||
|
||||
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
|
||||
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
|
||||
|
||||
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
|
||||
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
|
||||
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
|
||||
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
|
||||
exception is contained (logged + `Failed` + pump stops) rather than crashing.
|
||||
|
||||
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
|
||||
|
||||
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
|
||||
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
|
||||
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition` →
|
||||
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
|
||||
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
|
||||
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
|
||||
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
|
||||
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
|
||||
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
|
||||
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
|
||||
value on a property the binding owns.
|
||||
|
||||
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
|
||||
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
|
||||
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
|
||||
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
|
||||
… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
|
||||
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
|
||||
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
|
||||
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
|
||||
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
|
||||
later re-saves the entry with the icon fixed, the name persists with it.
|
||||
|
||||
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
|
||||
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
|
||||
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
|
||||
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
|
||||
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
|
||||
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
|
||||
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
|
||||
`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
|
||||
load/save/notify) and feeds the seam.
|
||||
|
||||
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
|
||||
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
|
||||
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
|
||||
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
|
||||
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
|
||||
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
|
||||
(`SocialBarSnap`) were removed with the click-toggle supersession.
|
||||
|
||||
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
|
||||
|
||||
---
|
||||
|
||||
@@ -200,6 +530,14 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
|
||||
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
||||
|
||||
### Status: ⏳ Not started
|
||||
|
||||
1. ☐ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above
|
||||
2. ☐ Reusable stream — create once, cache + reuse; bind to broadcast
|
||||
3. ☐ Health monitoring — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
|
||||
4. ☐ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||
5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||
|
||||
### Design decisions (v3)
|
||||
|
||||
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
|
||||
@@ -218,14 +556,21 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||
|
||||
### Status: Not started
|
||||
|
||||
---
|
||||
|
||||
## TASK 6 — Layout Persistence (SQLite)
|
||||
|
||||
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**)
|
||||
2. ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
|
||||
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
|
||||
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
|
||||
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
|
||||
6. ✅ Schema v1 → v8 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
||||
@@ -238,28 +583,49 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
||||
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
||||
add/remove/reorder, and any source transform change; flush on window close.
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder), `Asset` (Id, Hash, Data,
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data,
|
||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
|
||||
`user_version` **6** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
|
||||
`Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
|
||||
round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey` for the live-capture
|
||||
backdrop; v6 = `Scene.HasBackdrop` — the backdrop is **Live-only by policy**
|
||||
(one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||
sources; `EnforceBackdropPolicy` re-normalizes every load). `WindowHandle` stays in-memory
|
||||
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
|
||||
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
|
||||
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
|
||||
`ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
|
||||
v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
|
||||
+ `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` — the backdrop is
|
||||
**Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||
sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle
|
||||
dropped — column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software`
|
||||
fediverse-software column is a column-presence migration (commented v8→v9, no version bump).
|
||||
`WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
6. **Startup** — load the active file; seed the five canonical scenes
|
||||
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
|
||||
button re-adds a missing canonical scene and is hidden once all five are present;
|
||||
adding beyond the five is rejected — work with less, never more.
|
||||
|
||||
### Status: ✅ Implemented
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
- v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
||||
- v0.3 — Stream scheduling
|
||||
- v0.4 — Multi-destination restreaming
|
||||
- v0.5 — Stream clipping
|
||||
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
||||
2. v0.3 — Stream scheduling
|
||||
3. v0.4 — Multi-destination restreaming
|
||||
4. v0.5 — Stream clipping
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
ytLlive — Third-Party Notices
|
||||
================================
|
||||
|
||||
ytLlive is a paid, closed-source product. This file lists every third-party
|
||||
component the product distributes or downloads, its license, and where to get
|
||||
its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
|
||||
are NOT optional: they attach because this product ships or automates the
|
||||
download of these components.
|
||||
|
||||
If this file changes, update it here AND in the app's About screen (the About
|
||||
overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
|
||||
ai.md ("Licensing — do not violate") for the guardrails — the "never do" list is
|
||||
there on purpose.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
1. FFmpeg (dynamic libraries + ffmpeg.exe, LGPL v2.1+)
|
||||
Copyright (c) 2000-2026 the FFmpeg developers
|
||||
License: GNU Lesser General Public License v2.1 or later
|
||||
Home: https://ffmpeg.org/
|
||||
Source: https://git.ffmpeg.org/ffmpeg.git
|
||||
Used as: the streaming encoder/RTMP subprocess. This product distributes the
|
||||
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
|
||||
launched as a separate process fed raw frames over a pipe).
|
||||
Why LGPL (not GPL): a GPL build (e.g. gyan.dev, or BtbN's "gpl" variant)
|
||||
would contaminate this proprietary product. Do NOT use one.
|
||||
Why "shared" (not static): LGPL v2.1 §6 requires "relinkable" materials for
|
||||
statically-linked libraries. The shared build links dynamically, so
|
||||
the user can replace the DLLs — compliance is this notice plus the
|
||||
source offer below, with no relink material required.
|
||||
Compliance supplied by this product:
|
||||
- The license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
|
||||
- The corresponding source / written offer to obtain it:
|
||||
FFmpeg source https://ffmpeg.org/download.html
|
||||
Exact binary https://github.com/BtbN/FFmpeg-Builds
|
||||
Build tag: autobuild-2026-08-09-13-03 (variant lgpl-shared)
|
||||
- The binaries are unmodified and the LGPL notices therein are intact.
|
||||
|
||||
2. BtbN FFmpeg-Builds (the exact binary this product downloads)
|
||||
License: MIT (build scripts + repository) — the produced binaries are
|
||||
covered by FFmpeg's LGPL (item 1).
|
||||
Home: https://github.com/BtbN/FFmpeg-Builds
|
||||
|
||||
3. OpenH264 (libopenh264 — the H.264 software fallback encoder)
|
||||
Copyright (c) 2010-2026 Cisco Systems, Inc. (and contributors)
|
||||
License: BSD 2-Clause + Cisco's H.264 patent grant
|
||||
Home: https://www.openh264.org/
|
||||
Note: Cisco grants the patent license for its own H.264 implementation;
|
||||
it ships inside the FFmpeg build above (LGPL obligations of item 1
|
||||
apply to the library; the BSD terms apply to Cisco's code).
|
||||
|
||||
4. SQLite (bundled via SQLitePCLRaw's e_sqlite3 native bundle)
|
||||
License: public domain (no rights reserved)
|
||||
Home: https://www.sqlite.org/
|
||||
|
||||
5. Microsoft.Data.Sqlite (.NET data provider, statically linked into this app)
|
||||
Copyright (c) .NET Foundation and contributors
|
||||
License: MIT
|
||||
Home: https://github.com/dotnet/efcore
|
||||
|
||||
6. SQLitePCLRaw (raw SQLite bindings + bundles)
|
||||
Copyright (c) 2012-2026 Eric Sink and contributors
|
||||
License: Apache-2.0
|
||||
Home: https://github.com/ericstj/SQLitePCLRaw
|
||||
|
||||
7. .NET runtime / WPF / Windows SDK projections, incl.
|
||||
System.Security.Cryptography.ProtectedData
|
||||
Copyright (c) .NET Foundation and contributors
|
||||
License: MIT
|
||||
Home: https://github.com/dotnet/
|
||||
|
||||
8. Simple Icons (bundled SVG logo path data for the social bar, e.g. the
|
||||
mastodon/peertube/pixelfed fediverse logos)
|
||||
Copyright (c) Simple Icons contributors (public domain dedication)
|
||||
License: CC0 1.0 Universal (no rights reserved, no attribution required)
|
||||
Home: https://simpleicons.org/
|
||||
Source: https://github.com/simple-icons/simple-icons
|
||||
Used as: path data compiled into the app's `SocialServiceIcons` so each
|
||||
social entry renders the service's real logo. CC0 imposes no
|
||||
obligations; it is listed here per this file's "list everything"
|
||||
policy.
|
||||
|
||||
9. NAudio (WASAPI audio capture — loopback + mic)
|
||||
Copyright (c) Mark Heath and contributors
|
||||
License: MIT
|
||||
Home: https://github.com/naudio/NAudio
|
||||
Used as: the WASAPI loopback (desktop/game audio) and mic capture sources
|
||||
behind the `IAudioSource` seam (TASK 4 ship step 4). MIT imposes
|
||||
no source offer; this notice is kept per this file's policy.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
MISCELLANEOUS
|
||||
- The full text of every license named above is available at the linked
|
||||
canonical locations. The v1 (GA) release MUST additionally bundle the full
|
||||
license texts alongside this file (a `licenses/` folder beside it, still
|
||||
reachable from the About screen) — TASK 4 requirement 9, a release blocker.
|
||||
- No warranty is expressed or implied for any third-party component.
|
||||
@@ -43,6 +43,7 @@
|
||||
|
||||
<Style x:Key="IconButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="#d0d0d0"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="4"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
|
||||
+663
-52
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
@@ -13,6 +14,9 @@ using Microsoft.Win32;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
@@ -23,6 +27,8 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly YouTubeChatService _youtubeChat;
|
||||
private readonly DispatcherTimer _liveTimer;
|
||||
private readonly DispatcherTimer _volumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameAudioTimer;
|
||||
|
||||
private Scene? _activeScene;
|
||||
private SceneElement? _selectedElement;
|
||||
@@ -37,6 +43,14 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _micMuted;
|
||||
private double? _volumeBeforeMute;
|
||||
private string? _micSourceName;
|
||||
private MicStatus _micStatus = MicStatus.NotConnected;
|
||||
private double _gameAudioLevel;
|
||||
private bool _gameVolumeAdjusting;
|
||||
private bool _gameVolumeFlash;
|
||||
private double _gameVolume = 1.0;
|
||||
private bool _gameMuted;
|
||||
private double? _gameVolumeBeforeMute;
|
||||
private bool _isGameAudioBarVisible;
|
||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||
private StreamHealth _currentHealth = new();
|
||||
private string _streamTitle = string.Empty;
|
||||
@@ -47,7 +61,7 @@ public class MainViewModel : ViewModelBase
|
||||
private string _previewGlowBrush = "Transparent";
|
||||
private Thickness _previewGlowThickness = new(0);
|
||||
private string _liveElapsedText = "00:00:00";
|
||||
private double _livePulseOpacity = 1.0;
|
||||
private double _recDotPulse = 1.0;
|
||||
private TimeSpan _liveElapsed;
|
||||
|
||||
private bool _isSettingsOpen;
|
||||
@@ -74,15 +88,32 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly ICameraEnumerator _cameraEnumerator;
|
||||
private readonly CameraManager _cameraManager;
|
||||
private Webcam? _webcam;
|
||||
|
||||
private string? _webcamError;
|
||||
private SocialsConfig? _socials;
|
||||
private VideoFrame? _socialBarFrame;
|
||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||
|
||||
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
|
||||
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
|
||||
// lifetime (started at startup, stopped on shutdown) so the footer meters
|
||||
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
|
||||
// feeds the game audio bar's meter. Private by design — the mixer surfaces
|
||||
// the levels + mic connection state to the UI.
|
||||
private readonly AudioMixer _audioMixer;
|
||||
|
||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
||||
private readonly Func<string?> _rtmpUrlProvider;
|
||||
private readonly FramePump _framePump;
|
||||
|
||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
||||
// shows at the bottom layer. One shared capture session per key — the
|
||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||
private readonly IFullScreenDetector _fullScreenDetector;
|
||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
||||
private readonly IGameAudioDetector _gameAudioDetector;
|
||||
private int? _lastForegroundFullScreenMonitor;
|
||||
private CancellationTokenSource? _deactivateCts;
|
||||
private ImageSource? _backdropImage;
|
||||
@@ -168,9 +199,50 @@ public class MainViewModel : ViewModelBase
|
||||
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||
public bool CanChangeWebcam => _webcam != null;
|
||||
|
||||
// ─── Social bar (global resource, shown on every scene) ───
|
||||
|
||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
||||
private static SolidColorBrush CreateBrush(string hex)
|
||||
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
||||
|
||||
/// <summary>True when the global socials config has at least one validated entry.</summary>
|
||||
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
|
||||
|
||||
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
|
||||
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
|
||||
|
||||
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
|
||||
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
|
||||
|
||||
/// <summary>Green glow on the bar while it's on the stream.</summary>
|
||||
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
|
||||
|
||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||
public SocialsConfig? Socials => _socials;
|
||||
|
||||
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
|
||||
private static bool IsPremium => false; // itch.io unlock deferred — seam only
|
||||
|
||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||
|
||||
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
||||
|
||||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||||
|
||||
/// <summary>
|
||||
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
|
||||
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
|
||||
/// silent empty box. Cleared the moment a real frame arrives.
|
||||
/// </summary>
|
||||
public string? WebcamError
|
||||
{
|
||||
get => _webcamError;
|
||||
private set => SetProperty(ref _webcamError, value);
|
||||
}
|
||||
|
||||
public StreamStatus StreamStatus
|
||||
{
|
||||
get => _streamStatus;
|
||||
@@ -180,7 +252,10 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
OnPropertyChanged(nameof(IsLive));
|
||||
OnPropertyChanged(nameof(LiveIndicatorVisible));
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
OnPropertyChanged(nameof(ShowStartStream));
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
@@ -213,8 +288,9 @@ public class MainViewModel : ViewModelBase
|
||||
private set => SetProperty(ref _accountDisplayName, value);
|
||||
}
|
||||
|
||||
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
|
||||
// ─── only audio control is the mic: meter + volume + mute.) ───
|
||||
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
|
||||
// ─── visible; the game bar (meter + volume + mute) appears only while a
|
||||
// ─── full-screen game is producing sound. Both meters preview live. ───
|
||||
|
||||
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
|
||||
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
|
||||
@@ -236,7 +312,7 @@ public class MainViewModel : ViewModelBase
|
||||
/// level flashes on the bar) so the creator sees where they're setting it;
|
||||
/// otherwise the realtime live level scaled by volume (raising the volume
|
||||
/// moves ambient noise up the bar). Read-only meter.</summary>
|
||||
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevel * MicVolume);
|
||||
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume);
|
||||
|
||||
public double MeterFillWidth => MeterLevel * 288;
|
||||
|
||||
@@ -321,7 +397,8 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
|
||||
|
||||
/// <summary>Name of the picked voice source, shown under the meter on line 2.</summary>
|
||||
/// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
|
||||
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
|
||||
public string? MicSourceName
|
||||
{
|
||||
get => _micSourceName;
|
||||
@@ -349,12 +426,178 @@ public class MainViewModel : ViewModelBase
|
||||
Owner = System.Windows.Application.Current?.MainWindow
|
||||
};
|
||||
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
|
||||
{
|
||||
MicSourceName = dialog.PickedDevice.DisplayName;
|
||||
_audioMixer.RestartMic(); // swap the live device immediately
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mic status dot (the MIC button) ───
|
||||
|
||||
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
|
||||
|
||||
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
|
||||
/// events and the startup device check (see StartMicCaptureAsync).</summary>
|
||||
public MicStatus MicStatus
|
||||
{
|
||||
get => _micStatus;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _micStatus, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(MicStatusBrush));
|
||||
OnPropertyChanged(nameof(MicStatusToolTip));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status dot: green = connected, yellow = problem with the requested
|
||||
/// connection, red = not connected (no device / not started).</summary>
|
||||
public SolidColorBrush MicStatusBrush => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => BarOnBrush,
|
||||
MicStatus.Problem => MicProblemBrush,
|
||||
_ => BarOffBrush,
|
||||
};
|
||||
|
||||
public string MicStatusToolTip => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => "Mic connected — click to change",
|
||||
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
|
||||
_ => "No mic connected — click to choose a microphone",
|
||||
};
|
||||
|
||||
// ─── Game audio bar (desktop/game): visible only while a full-screen game
|
||||
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
|
||||
// ─── the mic bar. ───
|
||||
|
||||
/// <summary>True while the game audio bar should be shown (driven by
|
||||
/// IGameAudioDetector via the poll timer).</summary>
|
||||
public bool IsGameAudioBarVisible
|
||||
{
|
||||
get => _isGameAudioBarVisible;
|
||||
private set => SetProperty(ref _isGameAudioBarVisible, value);
|
||||
}
|
||||
|
||||
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
|
||||
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
|
||||
public double GameAudioLevel
|
||||
{
|
||||
get => _gameAudioLevel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Displayed game meter level: 0 while muted; the volume position
|
||||
/// while the slider is dragged (or briefly after an unmute flash); otherwise
|
||||
/// the realtime live level scaled by volume.</summary>
|
||||
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
|
||||
|
||||
public double GameMeterFillWidth => GameMeterLevel * 288;
|
||||
|
||||
public string GameMeterBrush => GameMeterLevel switch
|
||||
{
|
||||
< 0.6 => "#22c55e",
|
||||
< 0.8 => "#eab308",
|
||||
_ => "#ef4444",
|
||||
};
|
||||
|
||||
public void SetGameVolumeAdjusting(bool adjusting)
|
||||
{
|
||||
if (adjusting)
|
||||
CancelGameVolumeFlash();
|
||||
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlash = true;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlashTimer.Start();
|
||||
}
|
||||
|
||||
private void EndGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
if (_gameVolumeFlash)
|
||||
{
|
||||
_gameVolumeFlash = false;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlash = false;
|
||||
}
|
||||
|
||||
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
|
||||
/// prior level is remembered so the speaker button can restore it.</summary>
|
||||
public double GameAudioVolume
|
||||
{
|
||||
get => _gameVolume;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (clamped == 0 && !_gameMuted)
|
||||
_gameVolumeBeforeMute ??= _gameVolume;
|
||||
if (SetProperty(ref _gameVolume, clamped))
|
||||
{
|
||||
var muted = clamped == 0;
|
||||
if (_gameMuted != muted)
|
||||
{
|
||||
_gameMuted = muted;
|
||||
OnPropertyChanged(nameof(GameMuted));
|
||||
OnPropertyChanged(nameof(GameMuteText));
|
||||
}
|
||||
if (!muted)
|
||||
_gameVolumeBeforeMute = null;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
|
||||
/// speaker can never disagree).</summary>
|
||||
public bool GameMuted => _gameMuted;
|
||||
|
||||
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
|
||||
|
||||
private void ToggleGameMute()
|
||||
{
|
||||
if (GameMuted)
|
||||
{
|
||||
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
|
||||
BeginGameVolumeFlash();
|
||||
}
|
||||
else
|
||||
{
|
||||
_gameVolumeBeforeMute = GameAudioVolume;
|
||||
GameAudioVolume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool LiveIndicatorVisible => IsLive;
|
||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
|
||||
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
|
||||
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
|
||||
public bool ShowStartStream => IsOffline;
|
||||
public bool ShowChatInactiveMessage => !IsLive;
|
||||
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
|
||||
@@ -435,7 +678,15 @@ public class MainViewModel : ViewModelBase
|
||||
public string StreamVisibility
|
||||
{
|
||||
get => _streamVisibility;
|
||||
set => SetProperty(ref _streamVisibility, value);
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _streamVisibility, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WindowTitle
|
||||
@@ -468,12 +719,6 @@ public class MainViewModel : ViewModelBase
|
||||
set => SetProperty(ref _liveElapsedText, value);
|
||||
}
|
||||
|
||||
public double LivePulseOpacity
|
||||
{
|
||||
get => _livePulseOpacity;
|
||||
set => SetProperty(ref _livePulseOpacity, value);
|
||||
}
|
||||
|
||||
// Overlay panels
|
||||
public bool IsSettingsOpen
|
||||
{
|
||||
@@ -686,20 +931,21 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
// Commands
|
||||
public ICommand AddSceneCommand { get; }
|
||||
public ICommand EditSceneCommand { get; }
|
||||
public ICommand RemoveSceneCommand { get; }
|
||||
public ICommand ToggleSceneVisibilityCommand { get; }
|
||||
public ICommand AddSourceCommand { get; }
|
||||
public ICommand AddWebcamCommand { get; }
|
||||
public ICommand AddImageCommand { get; }
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand EditElementCommand { get; }
|
||||
public ICommand ToggleElementVisibilityCommand { get; }
|
||||
public ICommand ChangeWebcamCommand { get; }
|
||||
public ICommand ShowWebcamCommand { get; }
|
||||
public ICommand ChangeCaptureCommand { get; }
|
||||
public ICommand RefreshCaptureCommand { get; }
|
||||
public ICommand SetBackdropDisplayCommand { get; }
|
||||
public ICommand ToggleMicMuteCommand { get; }
|
||||
public ICommand ToggleGameMuteCommand { get; }
|
||||
public ICommand OpenMicPickerCommand { get; }
|
||||
public ICommand OpenSocialDialogCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
@@ -734,6 +980,9 @@ public class MainViewModel : ViewModelBase
|
||||
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
|
||||
|
||||
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
|
||||
|
||||
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
||||
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
||||
|
||||
@@ -745,19 +994,20 @@ public class MainViewModel : ViewModelBase
|
||||
Scenes.CollectionChanged += OnScenesChanged;
|
||||
|
||||
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
|
||||
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
|
||||
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
|
||||
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
|
||||
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
|
||||
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
|
||||
AddImageCommand = new RelayCommand(_ => AddImage());
|
||||
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
||||
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
||||
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
|
||||
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
||||
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
|
||||
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
|
||||
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
||||
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||
@@ -782,12 +1032,31 @@ public class MainViewModel : ViewModelBase
|
||||
id => new MediaCaptureFrameSource(id),
|
||||
System.Windows.Application.Current?.Dispatcher);
|
||||
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
||||
_cameraManager.CameraFailed += OnCameraFailed;
|
||||
|
||||
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
|
||||
|
||||
_audioMixer = new AudioMixer(
|
||||
new WasapiMicAudioSource(() => MicSourceName),
|
||||
new WasapiLoopbackAudioSource(),
|
||||
message => AppLog.Write(message));
|
||||
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
||||
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
||||
_audioMixer.MicConnected += OnMicConnected;
|
||||
_audioMixer.MicFailed += OnMicFailed;
|
||||
_ = StartMicCaptureAsync();
|
||||
|
||||
_fullScreenDetector = new Win32FullScreenDetector();
|
||||
foreach (var display in _fullScreenDetector.GetDisplays())
|
||||
Displays.Add(display);
|
||||
|
||||
_gameAudioDetector = new GameAudioDetector(
|
||||
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
|
||||
() => (float)GameAudioLevel);
|
||||
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
|
||||
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
_gameAudioTimer.Tick += OnGameAudioPollTick;
|
||||
_gameAudioTimer.Start();
|
||||
_screenCaptureFactory = new ScreenCaptureSourceFactory(
|
||||
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
|
||||
_screenCaptureManager = new ScreenCaptureManager(
|
||||
@@ -797,6 +1066,18 @@ public class MainViewModel : ViewModelBase
|
||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
||||
|
||||
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
|
||||
_framePump = new FramePump(
|
||||
sceneProvider: () => ActiveScene,
|
||||
frameResolver: ResolveOutputFrame,
|
||||
compositorOptions: BuildCompositorOptions,
|
||||
encoderOptions: BuildEncoderOptions,
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
|
||||
_framePump.Failed += OnFramePumpFailed;
|
||||
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
|
||||
|
||||
LoadLayout();
|
||||
_ = LoadSavedSessionAsync();
|
||||
AppLog.Write("MainViewModel ctor end");
|
||||
@@ -887,6 +1168,12 @@ public class MainViewModel : ViewModelBase
|
||||
UpdateBackdropImage();
|
||||
ReacquireWebcam();
|
||||
ReacquireScreenCaptures();
|
||||
_socials = _layoutStore.Socials;
|
||||
RenderSocialBarFrame();
|
||||
HealFediverseSoftwareInBackground();
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
ScheduleSave();
|
||||
AppLog.Write("LoadLayout end");
|
||||
}
|
||||
@@ -920,15 +1207,25 @@ public class MainViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
// CameraManager creates the shared WriteableBitmap on the UI thread at the
|
||||
// device's frame size; every scene's webcam config picks it up from here.
|
||||
// device's frame size; every scene's webcam config picks it up from here. A
|
||||
// bitmap means a real frame arrived — the camera is provably alive.
|
||||
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
||||
{
|
||||
if (_webcam?.DeviceId != deviceId) return;
|
||||
WebcamError = null;
|
||||
foreach (var scene in Scenes)
|
||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||
config.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
// The camera failed to start or died asynchronously (in use, offline, locked,
|
||||
// no frames within the proof timeout) — surface it instead of a silent box.
|
||||
private void OnCameraFailed(string deviceId, string message)
|
||||
{
|
||||
if (_webcam?.DeviceId != deviceId) return;
|
||||
WebcamError = $"Webcam offline: {message}";
|
||||
}
|
||||
|
||||
// ─── Screen backdrop capture (live desktop/game) ───
|
||||
|
||||
private const string MonitorKeyPrefix = "monitor:";
|
||||
@@ -1095,6 +1392,9 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_saveDebounce?.Stop();
|
||||
SaveLayoutNow();
|
||||
_gameAudioTimer.Stop();
|
||||
_audioMixer.Dispose();
|
||||
_framePump.Dispose();
|
||||
_cameraManager.Dispose();
|
||||
_screenCaptureManager.Dispose();
|
||||
_layoutStore.Dispose();
|
||||
@@ -1105,7 +1405,7 @@ public class MainViewModel : ViewModelBase
|
||||
_saveDebounce?.Stop();
|
||||
try
|
||||
{
|
||||
_layoutStore.Save(Scenes, _webcam);
|
||||
_layoutStore.Save(Scenes, _webcam, _socials);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1222,26 +1522,16 @@ public class MainViewModel : ViewModelBase
|
||||
ActiveScene = scene;
|
||||
}
|
||||
|
||||
private void BeginEditScene(Scene? scene)
|
||||
private void BeginEditElement(SceneElement? element)
|
||||
{
|
||||
if (scene == null) return;
|
||||
scene.IsEditing = true;
|
||||
if (element == null) return;
|
||||
element.IsEditing = true;
|
||||
}
|
||||
|
||||
private void ToggleSceneVisibility(Scene? scene)
|
||||
private void ToggleElementVisibility(SceneElement? element)
|
||||
{
|
||||
if (scene == null) return;
|
||||
scene.IsHidden = !scene.IsHidden;
|
||||
if (scene.IsHidden && ActiveScene == scene)
|
||||
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
|
||||
}
|
||||
|
||||
private void RemoveScene(Scene? scene)
|
||||
{
|
||||
if (scene == null) return;
|
||||
Scenes.Remove(scene);
|
||||
if (ActiveScene == scene)
|
||||
ActiveScene = Scenes.FirstOrDefault();
|
||||
if (element == null) return;
|
||||
element.IsVisible = !element.IsVisible;
|
||||
}
|
||||
|
||||
private void AddSource(object? parameter)
|
||||
@@ -1284,15 +1574,30 @@ public class MainViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
|
||||
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
|
||||
|
||||
scene.Elements.Add(new Source { Name = name, Type = sourceType });
|
||||
scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
UpdateActiveBackground();
|
||||
}
|
||||
|
||||
// Duplicate resource names get an incrementing suffix with no space: Image,
|
||||
// Image2, Image3… The next free number is derived from the names actually in
|
||||
// the scene, so deleting a middle resource never collides with a survivor.
|
||||
private static string NextSourceName(Scene scene, string baseName)
|
||||
{
|
||||
var taken = scene.Elements.OfType<Source>()
|
||||
.Select(s => s.Name)
|
||||
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|
||||
|| (n.Length > baseName.Length
|
||||
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(n.Substring(baseName.Length), out _)))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!taken.Contains(baseName)) return baseName;
|
||||
for (var i = 2; ; i++)
|
||||
if (!taken.Contains($"{baseName}{i}"))
|
||||
return $"{baseName}{i}";
|
||||
}
|
||||
|
||||
// Adds the webcam to the active scene. The creator ALWAYS picks from the
|
||||
// cameras Windows has registered — never silently resurrects the previous
|
||||
// camera (which is what happened after deleting one scene's webcam while
|
||||
@@ -1347,7 +1652,7 @@ public class MainViewModel : ViewModelBase
|
||||
if (!started)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
@@ -1371,7 +1676,7 @@ public class MainViewModel : ViewModelBase
|
||||
if (!started)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
break;
|
||||
}
|
||||
@@ -1507,10 +1812,7 @@ public class MainViewModel : ViewModelBase
|
||||
var scene = ActiveScene;
|
||||
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
||||
|
||||
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
|
||||
var name = count == 0 ? "Image" : $"Image {count + 1}";
|
||||
|
||||
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
|
||||
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
|
||||
|
||||
var image = ImageCache.Get(assetId);
|
||||
if (image != null)
|
||||
@@ -1664,6 +1966,8 @@ public class MainViewModel : ViewModelBase
|
||||
? "ytLlive"
|
||||
: $"{dialog.StreamTitle} — ytLlive";
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
ResetHealth(StreamStatus.Streaming);
|
||||
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1671,6 +1975,10 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
WindowTitle = "ytLlive";
|
||||
ResetHealth(StreamStatus.Offline);
|
||||
// Audio capture is always-on (preview monitoring); only the frame pump
|
||||
// and the session stop here.
|
||||
_ = _framePump.StopAsync();
|
||||
// Graceful end completes the session = signs out (the DPAPI token is
|
||||
// cleared so the next Start Stream requires a fresh sign-in). A crash
|
||||
// never runs this, so the token survives and the creator stays signed in.
|
||||
@@ -1681,6 +1989,170 @@ public class MainViewModel : ViewModelBase
|
||||
AppLog.Write("Stream ended; session signed out");
|
||||
}
|
||||
|
||||
private void OnMicLevelChanged(float level)
|
||||
{
|
||||
// NAudio raises on its capture thread; marshal to the UI thread so the
|
||||
// meter binding updates safely.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => AudioLevel = level);
|
||||
else
|
||||
AudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnLoopbackLevelChanged(float level)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => GameAudioLevel = level);
|
||||
else
|
||||
GameAudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnMicConnected()
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
|
||||
else
|
||||
MicStatus = MicStatus.Connected;
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
// The mixer logs the failure detail; here we only flip the dot to yellow.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
|
||||
else
|
||||
MicStatus = MicStatus.Problem;
|
||||
}
|
||||
|
||||
/// <summary>Starts capture once at startup: with no mic device present the
|
||||
/// dot stays red and capture never starts; otherwise the mixer starts and
|
||||
/// raises MicConnected (green) or MicFailed (yellow).</summary>
|
||||
private async Task StartMicCaptureAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
|
||||
if (mics.Count == 0)
|
||||
{
|
||||
MicStatus = MicStatus.NotConnected;
|
||||
AppLog.Write("Mic: no capture devices found — mic capture not started");
|
||||
return;
|
||||
}
|
||||
_audioMixer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Mic: device check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
|
||||
|
||||
private void OnGameAudioPollTick(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_gameAudioDetector.Poll();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Game audio detection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
|
||||
// images/background by AssetId. A null frame leaves the element transparent.
|
||||
private VideoFrame? ResolveOutputFrame(SceneElement element)
|
||||
{
|
||||
return element switch
|
||||
{
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
private CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
return new CompositorOptions
|
||||
{
|
||||
SourceRectX = (int)Math.Round(OutputRectX),
|
||||
SourceRectY = (int)Math.Round(OutputRectY),
|
||||
SourceRectWidth = (int)Math.Round(OutputRectWidth),
|
||||
SourceRectHeight = (int)Math.Round(OutputRectHeight),
|
||||
OutputWidth = quality.Width,
|
||||
OutputHeight = quality.Height,
|
||||
};
|
||||
}
|
||||
|
||||
// Full encoder options for the current tier, or null when no RTMP URL is
|
||||
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
var quality = SelectedQuality;
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
Fps = quality.Fps,
|
||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
private void OnFramePumpFailed(object? sender, string message)
|
||||
{
|
||||
AppLog.Write($"Frame pump failed: {message}");
|
||||
if (IsLive) StreamStatus = StreamStatus.Error;
|
||||
}
|
||||
|
||||
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
||||
// duration) lands in the bottom bar. The stderr loop raises on a background
|
||||
// thread — marshal to the UI thread like the audio level handlers.
|
||||
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => ApplyHealth(health));
|
||||
else
|
||||
ApplyHealth(health);
|
||||
}
|
||||
|
||||
private void ApplyHealth(StreamHealth health)
|
||||
{
|
||||
CurrentHealth.Status = health.Status;
|
||||
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
|
||||
CurrentHealth.FPS = health.FPS;
|
||||
CurrentHealth.DroppedFrames = health.DroppedFrames;
|
||||
CurrentHealth.StreamDuration = health.StreamDuration;
|
||||
CurrentHealth.LastError = health.LastError;
|
||||
CurrentHealth.HealthMessage = health.HealthMessage;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
}
|
||||
|
||||
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
|
||||
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
|
||||
// on the tier's targets — ApplyStreamQuality sets them on pick).
|
||||
private void ResetHealth(StreamStatus status)
|
||||
{
|
||||
CurrentHealth.Status = status;
|
||||
CurrentHealth.DroppedFrames = 0;
|
||||
CurrentHealth.StreamDuration = TimeSpan.Zero;
|
||||
CurrentHealth.LastError = null;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
}
|
||||
|
||||
private void UpdateLiveVisuals()
|
||||
{
|
||||
var live = IsLive;
|
||||
@@ -1692,7 +2164,8 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
LiveElapsedText = "00:00:00";
|
||||
LivePulseOpacity = 1.0;
|
||||
_recDotPulse = 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
_liveTimer.Start();
|
||||
if (BrandFlashEnabled) StartBrandFlashTimer();
|
||||
}
|
||||
@@ -1703,7 +2176,8 @@ public class MainViewModel : ViewModelBase
|
||||
_brandFlashOffTimer.Stop();
|
||||
BrandFlashActive = false;
|
||||
LiveElapsedText = "00:00:00";
|
||||
LivePulseOpacity = 1.0;
|
||||
_recDotPulse = 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1736,6 +2210,143 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
}
|
||||
|
||||
// ─── Social bar ───
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
|
||||
/// fediverse entry that doesn't have one, so the bar shows the instance's real
|
||||
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
|
||||
/// leaves the glyph untouched. Returns handle → software for what was resolved —
|
||||
/// the caller applies + persists (the app hops to the dispatcher; a test applies
|
||||
/// directly).
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
|
||||
SocialsConfig socials,
|
||||
ISocialValidator validator,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
var resolved = new Dictionary<string, string>();
|
||||
if (socials == null || validator == null) return resolved;
|
||||
foreach (var entry in socials.Entries)
|
||||
{
|
||||
if (entry.Service != SocialService.Fediverse
|
||||
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
|
||||
continue;
|
||||
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
|
||||
continue;
|
||||
var software = await validator.ResolveFediverseSoftwareAsync(
|
||||
domain, System.Threading.CancellationToken.None);
|
||||
if (string.IsNullOrWhiteSpace(software)) continue;
|
||||
resolved[entry.Handle] = software;
|
||||
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
|
||||
private void HealFediverseSoftwareInBackground()
|
||||
{
|
||||
var socials = _socials;
|
||||
if (socials == null) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = await HealFediverseSoftwareAsync(
|
||||
socials, _socialValidator, m => AppLog.Write(m));
|
||||
if (resolved.Count == 0) return;
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var current = _socials;
|
||||
if (current == null) return;
|
||||
var applied = false;
|
||||
foreach (var entry in current.Entries)
|
||||
{
|
||||
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
|
||||
if (entry.FediverseSoftware == software) continue;
|
||||
entry.FediverseSoftware = software;
|
||||
applied = true;
|
||||
}
|
||||
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Socials heal failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
|
||||
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
|
||||
/// read it from its own thread. Null when the bar is off or empty.
|
||||
/// </summary>
|
||||
private void RenderSocialBarFrame()
|
||||
{
|
||||
_socialBarFrame = _socials != null && _socials.BarEnabled
|
||||
? SocialBarRenderer.Render(_socials.Entries)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
||||
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
||||
/// </summary>
|
||||
private void OpenSocialDialog()
|
||||
{
|
||||
var dialog = new SocialsDialogViewModel(
|
||||
_socialValidator,
|
||||
SignInAsync,
|
||||
SignOutYouTubeAsync,
|
||||
_youtubeAuth.CurrentChannel,
|
||||
IsPremium,
|
||||
_socials);
|
||||
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
|
||||
if (window.ShowDialog() == true)
|
||||
{
|
||||
_socials = dialog.BuildSocialsConfig();
|
||||
NotifySocialsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifySocialsChanged()
|
||||
{
|
||||
RenderSocialBarFrame();
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
|
||||
public void SetSocialBarPosition(SocialBarPosition position)
|
||||
{
|
||||
if (_socials == null || _socials.BarPosition == position) return;
|
||||
_socials.BarPosition = position;
|
||||
OnPropertyChanged(nameof(SocialBarTop));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
|
||||
public void ToggleSocialBarPosition()
|
||||
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
|
||||
? SocialBarPosition.Bottom
|
||||
: SocialBarPosition.Top);
|
||||
|
||||
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
||||
private async Task SignOutYouTubeAsync()
|
||||
{
|
||||
_youtubeAuth.ClearSession();
|
||||
TokenStore.Clear();
|
||||
IsConnected = false;
|
||||
SyncConnectedAccount();
|
||||
NotifySocialsChanged();
|
||||
AppLog.Write("Socials: signed out of YouTube");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// A validated social entry in the dialog's working copy (non-YouTube slots).
|
||||
/// </summary>
|
||||
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
|
||||
|
||||
/// <summary>
|
||||
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
|
||||
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
|
||||
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
|
||||
/// the dialog's working list, so deleting an entry makes the ones above advance up.
|
||||
/// </summary>
|
||||
public sealed class SocialSlotViewModel : ViewModelBase
|
||||
{
|
||||
public int Index { get; }
|
||||
public bool IsYoutube => Index == 0;
|
||||
|
||||
private SocialService _service = SocialService.Link;
|
||||
public SocialService Service
|
||||
{
|
||||
get => _service;
|
||||
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
|
||||
}
|
||||
|
||||
public string LogoData => Service == SocialService.Fediverse
|
||||
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
|
||||
: SocialServiceIcons.LogoDataFor(Service);
|
||||
public string LockedIconData => SocialServiceIcons.LockedIconData;
|
||||
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
|
||||
|
||||
private bool _isLocked;
|
||||
public bool IsLocked
|
||||
{
|
||||
get => _isLocked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLocked, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowAddButton));
|
||||
OnPropertyChanged(nameof(ShowDoNot));
|
||||
OnPropertyChanged(nameof(DisplayText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isFilled;
|
||||
public bool IsFilled
|
||||
{
|
||||
get => _isFilled;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isFilled, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowEditButton));
|
||||
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||
OnPropertyChanged(nameof(ShowAddButton));
|
||||
OnPropertyChanged(nameof(ShowDoNot));
|
||||
OnPropertyChanged(nameof(DisplayText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isSignIn;
|
||||
public bool IsSignIn
|
||||
{
|
||||
get => _isSignIn;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isSignIn, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSignInButton));
|
||||
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||
OnPropertyChanged(nameof(ShowLogo));
|
||||
OnPropertyChanged(nameof(DisplayText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isEditing;
|
||||
public bool IsEditing
|
||||
{
|
||||
get => _isEditing;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isEditing, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowEditBox));
|
||||
OnPropertyChanged(nameof(ShowDisplay));
|
||||
OnPropertyChanged(nameof(ShowEditButton));
|
||||
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||
OnPropertyChanged(nameof(ShowAddButton));
|
||||
OnPropertyChanged(nameof(ShowDoNot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isValidating;
|
||||
public bool IsValidating
|
||||
{
|
||||
get => _isValidating;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isValidating, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowBusy));
|
||||
OnPropertyChanged(nameof(ShowDoNot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _handle = string.Empty;
|
||||
public string Handle
|
||||
{
|
||||
get => _handle;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _handle, value))
|
||||
OnPropertyChanged(nameof(DisplayText));
|
||||
}
|
||||
}
|
||||
|
||||
private string? _fediverseSoftware;
|
||||
public string? FediverseSoftware
|
||||
{
|
||||
get => _fediverseSoftware;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _fediverseSoftware, value))
|
||||
OnPropertyChanged(nameof(LogoData));
|
||||
}
|
||||
}
|
||||
|
||||
public string ProfileUrl { get; set; } = string.Empty;
|
||||
|
||||
private string? _error;
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _error, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
OnPropertyChanged(nameof(ShowError));
|
||||
OnPropertyChanged(nameof(ShowDoNot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _editText = string.Empty;
|
||||
public string EditText
|
||||
{
|
||||
get => _editText;
|
||||
set => SetProperty(ref _editText, value);
|
||||
}
|
||||
|
||||
/// <summary>The last text submitted for validation (used to skip re-validating
|
||||
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
|
||||
public string? LastValidatedInput { get; set; }
|
||||
|
||||
public string DisplayText =>
|
||||
IsYoutube
|
||||
? (IsSignIn ? "Sign in to YouTube" : Handle)
|
||||
: IsLocked ? "Unlock with Premium"
|
||||
: IsFilled ? Handle
|
||||
: "No social yet";
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(Error);
|
||||
public bool ShowError => HasError;
|
||||
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
|
||||
public bool ShowBusy => IsValidating;
|
||||
public bool ShowEditBox => IsEditing;
|
||||
public bool ShowDisplay => !IsEditing && !IsValidating;
|
||||
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
|
||||
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
|
||||
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
|
||||
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
|
||||
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
|
||||
|
||||
public SocialSlotViewModel(int index) => Index = index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// View model for the Social Media Site Promotion dialog. Owns the working copy
|
||||
/// of the social bar config: the six fixed slots, the sign-in gate, per-row
|
||||
/// validation, the freemium lock, and the YouTube sign-out. Deliberately WPF-free
|
||||
/// (no MessageBox, no Window) so the whole flow is unit-testable with fakes.
|
||||
/// The window surfaces the confirmations (delete, sign-in gate) as dialogs and
|
||||
/// calls back into the VM.
|
||||
/// </summary>
|
||||
public sealed class SocialsDialogViewModel : ViewModelBase
|
||||
{
|
||||
public const int MaxSlots = 6;
|
||||
|
||||
private readonly ISocialValidator _validator;
|
||||
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||
private readonly Func<Task> _signOutAction;
|
||||
private readonly bool _isPremium;
|
||||
private readonly List<DialogEntry> _working = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private YouTubeChannel? _account;
|
||||
|
||||
private bool _isSignedIn;
|
||||
private bool _isBusy;
|
||||
private bool _barEnabled = true;
|
||||
|
||||
public SocialsDialogViewModel(
|
||||
ISocialValidator validator,
|
||||
Func<Task<YouTubeChannel?>> signInProvider,
|
||||
Func<Task> signOutAction,
|
||||
YouTubeChannel? account,
|
||||
bool isPremium,
|
||||
SocialsConfig? current = null)
|
||||
{
|
||||
_validator = validator;
|
||||
_signInProvider = signInProvider;
|
||||
_signOutAction = signOutAction;
|
||||
_isPremium = isPremium;
|
||||
_barEnabled = current?.BarEnabled ?? true;
|
||||
_account = account;
|
||||
_isSignedIn = account != null;
|
||||
|
||||
foreach (var entry in current?.Entries ?? [])
|
||||
if (entry.Service != SocialService.YouTube)
|
||||
_working.Add(new DialogEntry(entry.Service, entry.Handle, entry.ProfileUrl));
|
||||
|
||||
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => CanSignIn);
|
||||
SaveCommand = new RelayCommand(_ => Save(), _ => CanSave);
|
||||
CancelCommand = new RelayCommand(_ => Cancel());
|
||||
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
public ObservableCollection<SocialSlotViewModel> Slots { get; } = new();
|
||||
|
||||
public bool BarEnabled
|
||||
{
|
||||
get => _barEnabled;
|
||||
set => SetProperty(ref _barEnabled, value);
|
||||
}
|
||||
|
||||
public bool IsSignedIn
|
||||
{
|
||||
get => _isSignedIn;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isSignedIn, value))
|
||||
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowSignInBanner => !IsSignedIn && !IsBusy;
|
||||
public bool CanSignIn => !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Save is blocked while any slot has un-validated input, is mid-validation,
|
||||
/// or failed validation — an empty slot or a confirmed entry never blocks.
|
||||
/// </summary>
|
||||
public bool CanSave =>
|
||||
!IsBusy &&
|
||||
Slots.All(s => !s.IsEditing || string.IsNullOrWhiteSpace(s.EditText)) &&
|
||||
Slots.All(s => !s.IsValidating && !s.HasError);
|
||||
|
||||
public ICommand SignInCommand { get; }
|
||||
public ICommand SaveCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public event Action? SaveRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
/// <summary>Entries to persist after a successful save (YouTube first when signed in).</summary>
|
||||
public IReadOnlyList<SocialEntry>? CommittedEntries { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dismissal is a hard stop: abort every in-flight validation and get out.
|
||||
/// The canceled continuations never touch slot state.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_cts.Cancel();
|
||||
CancelRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Aborts in-flight work without raising <see cref="CancelRequested"/>
|
||||
/// (used by the window's Closing handler so a Save close isn't clobbered).</summary>
|
||||
public void AbortPending() => _cts.Cancel();
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
if (IsBusy) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var channel = await _signInProvider();
|
||||
if (channel != null)
|
||||
{
|
||||
_account = channel;
|
||||
IsSignedIn = true;
|
||||
RebuildSlots();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts editing the slot's input box (add or edit).</summary>
|
||||
public void StartEdit(int slotIndex)
|
||||
{
|
||||
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||
var slot = Slots[slotIndex];
|
||||
if (slot.IsLocked || slot.IsYoutube || slot.IsValidating) return;
|
||||
slot.EditText = slot.IsFilled ? slot.Handle : string.Empty;
|
||||
slot.Error = null;
|
||||
slot.IsEditing = true;
|
||||
}
|
||||
|
||||
/// <summary>Commits the slot's input — empty cancels, otherwise validates.
|
||||
/// Re-submitting text that was already handled is a no-op, so a focus shift
|
||||
/// (clicking Cancel, tabbing away) never re-fires a lookup.</summary>
|
||||
public void ConfirmEdit(int slotIndex)
|
||||
{
|
||||
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||
var slot = Slots[slotIndex];
|
||||
if (!slot.IsEditing || slot.IsValidating) return;
|
||||
if (string.IsNullOrWhiteSpace(slot.EditText))
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
var text = slot.EditText.Trim();
|
||||
if (slot.IsFilled && text == slot.Handle)
|
||||
{
|
||||
slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
if (slot.LastValidatedInput == text)
|
||||
{
|
||||
if (slot.IsFilled) slot.IsEditing = false;
|
||||
return;
|
||||
}
|
||||
slot.LastValidatedInput = text;
|
||||
_ = ValidateAsync(slot);
|
||||
}
|
||||
|
||||
/// <summary>Runs the lookup; on success snaps the row back to its logo + handle.
|
||||
/// A canceled lookup (dismissal) leaves the slot untouched.</summary>
|
||||
private async Task ValidateAsync(SocialSlotViewModel slot)
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
slot.IsValidating = true;
|
||||
slot.Error = null;
|
||||
try
|
||||
{
|
||||
var text = slot.EditText.Trim();
|
||||
var (service, handle, _) = SocialServiceIcons.DetectService(text);
|
||||
var result = await _validator.LookupAsync(service, handle, ct);
|
||||
if (ct.IsCancellationRequested || result.Canceled) return;
|
||||
|
||||
if (result.Success)
|
||||
ApplyValidated(slot, new DialogEntry(service, result.Handle, result.ProfileUrl, result.FediverseSoftware));
|
||||
else
|
||||
slot.Error = result.Error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
slot.IsValidating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyValidated(SocialSlotViewModel slot, DialogEntry entry)
|
||||
{
|
||||
var index = slot.Index - 1;
|
||||
if (slot.IsFilled && index >= 0 && index < _working.Count)
|
||||
_working[index] = entry;
|
||||
else
|
||||
_working.Add(entry);
|
||||
slot.Service = entry.Service;
|
||||
slot.Handle = entry.Handle;
|
||||
slot.ProfileUrl = entry.ProfileUrl;
|
||||
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||
slot.IsFilled = true;
|
||||
slot.IsEditing = false;
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
/// <summary>The window confirms the delete, then calls this. Slot 0 signs out of YouTube.</summary>
|
||||
public async Task DeleteSlotAsync(int slotIndex)
|
||||
{
|
||||
if (slotIndex == 0)
|
||||
{
|
||||
if (!IsSignedIn) return;
|
||||
await _signOutAction();
|
||||
_account = null;
|
||||
IsSignedIn = false;
|
||||
RebuildSlots();
|
||||
return;
|
||||
}
|
||||
|
||||
var entryIndex = slotIndex - 1;
|
||||
if (entryIndex < 0 || entryIndex >= _working.Count) return;
|
||||
_working.RemoveAt(entryIndex);
|
||||
RebuildSlots();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
if (!CanSave) return;
|
||||
var entries = new List<SocialEntry>();
|
||||
if (IsSignedIn && _account != null)
|
||||
{
|
||||
var handle = _account.DisplayName.Trim().TrimStart('@');
|
||||
entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = handle,
|
||||
ProfileUrl = string.IsNullOrWhiteSpace(_account.ChannelId)
|
||||
? SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle)
|
||||
: $"https://www.youtube.com/channel/{_account.ChannelId}",
|
||||
});
|
||||
}
|
||||
foreach (var entry in _working)
|
||||
entries.Add(new SocialEntry
|
||||
{
|
||||
Service = entry.Service,
|
||||
Handle = entry.Handle,
|
||||
ProfileUrl = entry.ProfileUrl,
|
||||
FediverseSoftware = entry.FediverseSoftware,
|
||||
});
|
||||
CommittedEntries = entries;
|
||||
SaveRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Builds the config the main window commits on save, or null when empty.</summary>
|
||||
public SocialsConfig? BuildSocialsConfig()
|
||||
{
|
||||
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
|
||||
if (entries.Count == 0) return null;
|
||||
var config = new SocialsConfig { BarEnabled = BarEnabled };
|
||||
foreach (var entry in entries)
|
||||
config.Entries.Add(entry);
|
||||
return config;
|
||||
}
|
||||
|
||||
private void RebuildSlots()
|
||||
{
|
||||
foreach (var slot in Slots)
|
||||
slot.PropertyChanged -= OnSlotPropertyChanged;
|
||||
Slots.Clear();
|
||||
|
||||
for (var i = 0; i < MaxSlots; i++)
|
||||
{
|
||||
var slot = new SocialSlotViewModel(i);
|
||||
if (i == 0)
|
||||
{
|
||||
slot.Service = SocialService.YouTube;
|
||||
slot.IsSignIn = !IsSignedIn;
|
||||
slot.IsFilled = IsSignedIn;
|
||||
if (IsSignedIn && _account != null)
|
||||
slot.Handle = _account.DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
var entryIndex = i - 1;
|
||||
if (entryIndex < _working.Count)
|
||||
{
|
||||
var entry = _working[entryIndex];
|
||||
slot.Service = entry.Service;
|
||||
slot.IsFilled = true;
|
||||
slot.Handle = entry.Handle;
|
||||
slot.ProfileUrl = entry.ProfileUrl;
|
||||
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||
}
|
||||
else if (!_isPremium && i >= 2)
|
||||
{
|
||||
slot.IsLocked = true;
|
||||
}
|
||||
}
|
||||
slot.PropertyChanged += OnSlotPropertyChanged;
|
||||
Slots.Add(slot);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSlotPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(SocialSlotViewModel.IsEditing)
|
||||
or nameof(SocialSlotViewModel.IsValidating)
|
||||
or nameof(SocialSlotViewModel.HasError)
|
||||
or nameof(SocialSlotViewModel.EditText))
|
||||
{
|
||||
OnPropertyChanged(nameof(CanSave));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -59,8 +59,12 @@ ScreenCaptureManager refcount + shared-bitmap + coalescing
|
||||
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
|
||||
insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
|
||||
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
|
||||
(the per-scene size clamp incl. the Chat half-screen-area cap) —
|
||||
65 passing.
|
||||
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
|
||||
composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
|
||||
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
|
||||
(the PATH → cache → download decision ladder with a fake downloader serving a real in-memory zip; shared
|
||||
build DLL extraction) —
|
||||
78 passing.
|
||||
|
||||
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
|
||||
|
||||
@@ -85,9 +89,9 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel |
|
||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)** |
|
||||
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
|
||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
|
||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")** |
|
||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
|
||||
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||
@@ -96,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
|
||||
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevel * MicVolume)` (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
||||
@@ -108,9 +112,9 @@ C# / WPF (.NET 8) following MVVM:
|
||||
### Current limitations / TODOs
|
||||
|
||||
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||
- Scene/source/asset layout persists (SQLite, schema v6); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **window capture (non-backdrop), scene compositing/encoding, RTMP are next**
|
||||
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
|
||||
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
||||
|
||||
### Screen backdrop capture (TASK 3 ship task #1)
|
||||
@@ -176,6 +180,14 @@ instead of a normal draggable source.
|
||||
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
|
||||
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
|
||||
game are behind the picker; picker-based captures don't survive reload.
|
||||
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
|
||||
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
|
||||
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
|
||||
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
|
||||
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
|
||||
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
|
||||
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
|
||||
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
|
||||
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
|
||||
to the encoder task.
|
||||
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
|
||||
@@ -282,6 +294,239 @@ instead of a normal draggable source.
|
||||
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
|
||||
Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
|
||||
|
||||
### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md)
|
||||
|
||||
The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay,
|
||||
DimRects, output-rect outline, badge, placeholder). WPF's `RenderTargetBitmap` is software-rendered and
|
||||
captures the visual tree *including* chrome, so the preview can't be captured — the output is a **second,
|
||||
parallel software compositor** over the `VideoFrame` (BGRA8) seam, and the XAML preview
|
||||
(`MainWindow.xaml` CanvasGrid + element DataTemplate) is the rendering contract it replicates. Two
|
||||
renderers must agree: geometry, `UniformToFill` cover-crop, round clip, mirror, border, z-order. Preview
|
||||
stays XAML (editing view); the compositor is the output view.
|
||||
|
||||
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
|
||||
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
|
||||
- **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame
|
||||
anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers
|
||||
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
|
||||
if composition grows (wipes, filters, many layers), a D3D11 compositor can replace this one **behind
|
||||
the same seam** — the CPU master buffer stays the contract.
|
||||
- **Branding flash is composited by the output path too** (it's on the live output, per Monetization),
|
||||
passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a
|
||||
bundled asset rather than runtime text rendering (deterministic, no font/layout risk).
|
||||
- **Frame sources are injected** via a `Func<SceneElement, VideoFrame?>` resolver
|
||||
(`SceneCompositor.Render(scene, frameFor, flashFrame, options)`) — the caller maps each element to
|
||||
its frame (webcam → `DeviceId`, image → `AssetId` via `StaticPixelCache`, backdrop → `CaptureKey`),
|
||||
so the compositor is pure, WPF-free, and hermetic to test. The capture managers wire into that
|
||||
resolver in the encoder step, not the compositor step. The master buffer (the compositor's return
|
||||
value) is the seam a future D3D11 compositor would honor identically.
|
||||
|
||||
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md)
|
||||
|
||||
The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator`
|
||||
resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice,
|
||||
their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN
|
||||
LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted
|
||||
(staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's
|
||||
own directory). BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
|
||||
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
|
||||
source offer" with no static-relink (§6) material — see the Licensing guardrails below. The pin is a
|
||||
dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so
|
||||
a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one
|
||||
const). Constructor-injected search dirs / tools dir / downloader (`Func<string, CancellationToken,
|
||||
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
|
||||
encoder step (not yet — this PR ships the seam + impl + tests only).
|
||||
|
||||
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
|
||||
|
||||
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
|
||||
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
|
||||
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop),
|
||||
`SubmitFrameAsync` (serialized stdin writes under `SemaphoreSlim`), `StopAsync` (stdin EOF → ffmpeg
|
||||
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
|
||||
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
|
||||
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
|
||||
+ exit control); a `Func<IEncoderProcess>` factory + the locator are constructor-injected, so the
|
||||
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
|
||||
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
|
||||
|
||||
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
|
||||
`-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + a **silent placeholder
|
||||
`-f lavfi -i anullsrc`** track (WASAPI capture, ship step 4, replaces it) + `-c:v <enc> -b:v K
|
||||
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
|
||||
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>`.
|
||||
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
|
||||
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
|
||||
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
|
||||
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
|
||||
driven by the `FramePump` below.
|
||||
|
||||
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12, game audio bar follow-up 2026-08-13, plans in TASKS.md)
|
||||
|
||||
**Capture runs for the app's lifetime and is KISS by rule**: desktop/game audio is automatic (WASAPI
|
||||
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
|
||||
layer sits behind an **`IAudioSource` seam** (`Services/Audio/`: `Start`/`Stop`/`Started`/`SampleReady`/
|
||||
`Failed`, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
|
||||
devices, no timers).
|
||||
|
||||
- **Sources (NAudio `NAudio.Wasapi` 2.2.1, MIT — item 9 in `THIRD-PARTY-NOTICES.txt`):**
|
||||
`WasapiLoopbackAudioSource` = `WasapiLoopbackCapture` on the default render device;
|
||||
`WasapiMicAudioSource` = `WasapiCapture` with the device resolved by `FriendlyName` matching
|
||||
`MicSourceName` (the app only persists the **DisplayName**), falling back to the default capture
|
||||
endpoint. Mic device resolution re-reads the name provider `Func<string?>` at each `Start`, so a mic
|
||||
picked mid-session takes effect **immediately** (the mixer restarts the mic on pick). Both sources
|
||||
raise `Started` once their capture loop actually begins — the mixer turns that into `MicConnected`.
|
||||
- **`AudioMixer`** owns both sources; **`StartMicCaptureAsync` starts the mixer once at startup** and
|
||||
`Shutdown` disposes it — NOT go-live — so the meters preview live (`BeginGoLive`/`StopStream`
|
||||
no longer touch the mixer). Mic samples feed a pure **`AudioLevelMeter`** (RMS with 0.2 exponential
|
||||
smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`; loopback samples feed a
|
||||
second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level is
|
||||
mapped to the meter's display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1): real speech/
|
||||
game RMS is ~0.01..0.1 linear, which would leave a flat scale looking dead. **Mic connection state
|
||||
surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and
|
||||
`RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via
|
||||
`AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` is
|
||||
unconditional (a `?.` on the event would skip the argument — and the meter update — when nothing is
|
||||
subscribed yet).
|
||||
- **Mic status dot (`Models/MicStatus.cs`)** on the footer's MIC button: green = `MicConnected`,
|
||||
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
|
||||
so loopback and the game bar can't run either — no capture devices at all).
|
||||
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview):
|
||||
`IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
|
||||
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active
|
||||
bar**), and the default `GameAudioDetector` composing `IFullScreenDetector` + the live loopback level
|
||||
(floor 0.5%). WPF-free — the VM owns a 250ms `DispatcherTimer` that polls it and flips
|
||||
`IsGameAudioBarVisible`. The bar is **overlaid at the bottom of the preview window** (bottom-center,
|
||||
dark translucent chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI
|
||||
only — it lives in the XAML preview (`MainWindow.xaml`, the PreviewGrid) and never reaches the live
|
||||
output (the `SceneCompositor` doesn't know about it).
|
||||
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct,
|
||||
PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
|
||||
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
|
||||
- Build **0 warnings**; **169 passing** (mixer/hysteresis/game-detector/meter-scale unit tests).
|
||||
|
||||
**Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame
|
||||
pipeline shipped in ship step 5).
|
||||
|
||||
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
|
||||
|
||||
The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active
|
||||
scene each tick, resolves every element to its latest frame, composites it into the tier's output frame,
|
||||
and paces frames into the encoder at the tier's FPS. **Pattern — everything is a constructor-injected
|
||||
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, and an injectable pacing delay
|
||||
(default `Task.Delay`; tests inject `Task.Yield`). The pump is free of WPF and of the capture managers.
|
||||
|
||||
- **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log
|
||||
+ surface via the `Failed` event. `EncoderOptions == null` means "no RTMP URL": the pump logs and skips
|
||||
the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func<string?>` returning null
|
||||
until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow.
|
||||
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
|
||||
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
|
||||
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
|
||||
`StreamStatus.Error` (minimal).
|
||||
- **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar —
|
||||
`MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on
|
||||
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
|
||||
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
|
||||
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
|
||||
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
|
||||
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` →
|
||||
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` →
|
||||
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/
|
||||
background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*`
|
||||
doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`,
|
||||
ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one.
|
||||
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
|
||||
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
|
||||
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
|
||||
(WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread,
|
||||
the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render`
|
||||
blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` =
|
||||
bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame`
|
||||
on load/save/`NotifySocialsChanged`).
|
||||
- Known consideration: the pump reads the active scene on a background thread while the UI can still edit
|
||||
it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than
|
||||
crashing. The background thread + video pipeline is the new reality since this step.
|
||||
|
||||
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
|
||||
|
||||
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
|
||||
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
|
||||
**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
|
||||
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
|
||||
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
|
||||
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped,
|
||||
column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` +
|
||||
`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes):
|
||||
ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle;
|
||||
signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on
|
||||
freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` →
|
||||
**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam +
|
||||
`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure =
|
||||
rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` →
|
||||
`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column,
|
||||
column-presence migration, no version bump) so the entry shows the **instance's real logo**
|
||||
(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse
|
||||
honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates
|
||||
(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates
|
||||
`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup
|
||||
**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a
|
||||
`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
|
||||
never touch slot state).
|
||||
|
||||
**Bar bug-fix branch (2026-08-13) — three changes:**
|
||||
|
||||
1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that
|
||||
permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the
|
||||
bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on
|
||||
release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't
|
||||
let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom
|
||||
(`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone.
|
||||
2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because
|
||||
nodeinfo was only ever asked of the identity domain (a landing page; the real instance is
|
||||
`mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes
|
||||
well-known subdomains** (`mastodon.` → `social.` → … `FediverseSubdomainCandidates`) when the identity
|
||||
domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the
|
||||
static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher,
|
||||
and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place.
|
||||
3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a
|
||||
transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in,
|
||||
Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
|
||||
pipeline").
|
||||
|
||||
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
|
||||
|
||||
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
|
||||
guardrails below — written down so a future "quick fix" never reintroduces a GPL binary. **NEVER:**
|
||||
|
||||
- **Use a GPL FFmpeg build** — gyan.dev's builds are GPLv3 and ship libx264; BtbN's `gpl` variant is
|
||||
GPL too. GPL in a distributed paid product is the #1 lawsuit risk. Only BtbN `lgpl` / `lgpl-shared`
|
||||
builds are allowed.
|
||||
- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking.
|
||||
The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
|
||||
unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant.
|
||||
- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC
|
||||
encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for
|
||||
RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel
|
||||
QSV, AMD AMF).
|
||||
- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation
|
||||
keeps the app's own code out of LGPL reach.
|
||||
- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg
|
||||
copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution
|
||||
— the obligations are not optional.
|
||||
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
|
||||
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
|
||||
- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons**
|
||||
(CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or
|
||||
another public-domain source; a logo asset under a copyleft or attribution license would contaminate
|
||||
the paid product.
|
||||
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
|
||||
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
|
||||
release blocker). Queued early is wrong; the release pass owns it.
|
||||
|
||||
## Design Principle
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
|
||||
@@ -20,6 +20,7 @@ that every other memory file follows. Read `ai.md` first; this file explains
|
||||
| `README.md` | Human-facing intro: what the app is, how to run it, roadmap | no |
|
||||
| `ai.md` | **AI guide + session handoff** — architecture, patterns, decisions, the cognitive map home | **yes — start here** |
|
||||
| `TASKS.md` | Task queue + authoritative YouTube API research facts + task statuses | yes — for status |
|
||||
| `HANDOFF.md` | Current operational state: what's in flight, landmines, next step, secret/DB/port locations | yes — trust it as current state |
|
||||
| `schema.md` | This file: the conventions below | when in doubt |
|
||||
| `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code |
|
||||
| `Views/` | Reserved for Views; currently empty | — |
|
||||
@@ -51,6 +52,8 @@ that every other memory file follows. Read `ai.md` first; this file explains
|
||||
|
||||
## Integration rule
|
||||
|
||||
Every feature change ships with its memory update: `ai.md` for architecture /
|
||||
patterns, `TASKS.md` for status, index files when the layout changes. That is
|
||||
what keeps the map accurate enough to trust next session.
|
||||
Every feature change ships with its memory update **in the same commit**:
|
||||
`ai.md` for architecture / patterns, `TASKS.md` for status, index files when
|
||||
the layout changes. No code commit without its docs — a follow-up "docs
|
||||
backfill" commit is a broken rule, not a style. That is what keeps the map
|
||||
accurate enough to trust next session.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
using NAudio.Wave;
|
||||
using Xunit;
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 4 ship step 4: WASAPI audio capture behind the <see cref="IAudioSource"/>
|
||||
/// seam. The units pin down the pure pieces — byte→float conversion, the level
|
||||
/// meter math, and the mixer lifecycle/forwarding against fakes. No real audio
|
||||
/// devices (NAudio device resolution is a thin wrapper left to a manual smoke
|
||||
/// test), no timers.
|
||||
/// </summary>
|
||||
public class AudioMixerTests
|
||||
{
|
||||
private sealed class FakeSource : IAudioSource
|
||||
{
|
||||
public int StartCount { get; private set; }
|
||||
public int StopCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public event Action? Started;
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start() => StartCount++;
|
||||
public void Stop() => StopCount++;
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public void MarkStarted() => Started?.Invoke();
|
||||
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
|
||||
public void Fail(Exception ex) => Failed?.Invoke(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_StartsBothSources()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
|
||||
mixer.Start();
|
||||
|
||||
Assert.Equal(1, mic.StartCount);
|
||||
Assert.Equal(1, loopback.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_IsIdempotent()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
|
||||
mixer.Start();
|
||||
mixer.Start();
|
||||
|
||||
Assert.Equal(1, mic.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_StopsBothAndResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 0.8f }, 48000, 1));
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
mixer.Stop();
|
||||
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
Assert.Equal(1, loopback.StopCount);
|
||||
Assert.Equal(0f, last);
|
||||
Assert.Equal(0f, mixer.MicLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WithNoStart_DoesNothing()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
|
||||
mixer.Stop();
|
||||
|
||||
Assert.Equal(0, mic.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicSamples_DriveMicLevelChanged()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
mic.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 1));
|
||||
|
||||
Assert.NotEmpty(levels);
|
||||
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackSamples_DoNotChangeMicLevel()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||
|
||||
Assert.Empty(levels);
|
||||
Assert.Equal(0f, mixer.MicLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicSamples_DoNotChangeLoopbackLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var levels = new List<float>();
|
||||
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
|
||||
Assert.Empty(levels);
|
||||
Assert.Equal(0f, mixer.LoopbackLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackSamples_DriveLoopbackLevelChanged()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||
var levels = new List<float>();
|
||||
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||
|
||||
mixer.Start();
|
||||
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
|
||||
|
||||
Assert.NotEmpty(levels);
|
||||
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicStarted_RaisesMicConnected()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
var connected = 0;
|
||||
mixer.MicConnected += () => connected++;
|
||||
|
||||
mixer.Start();
|
||||
mic.MarkStarted();
|
||||
|
||||
Assert.Equal(1, connected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicFailure_RaisesMicFailed()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
Exception? failed = null;
|
||||
mixer.MicFailed += ex => failed = ex;
|
||||
|
||||
mixer.Start();
|
||||
mic.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("boom", failed!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
|
||||
mixer.RestartMic();
|
||||
|
||||
Assert.Equal(2, mic.StartCount);
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
Assert.Equal(1, loopback.StartCount);
|
||||
Assert.Equal(0, loopback.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartMic_ResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, new FakeSource());
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
Assert.True(mixer.MicLevel > 0);
|
||||
|
||||
float? reset = null;
|
||||
mixer.MicLevelChanged += l => reset = l;
|
||||
mixer.RestartMic();
|
||||
|
||||
Assert.Equal(0f, reset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicFailure_LogsAndResetsLevel()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var logs = new List<string>();
|
||||
var mixer = new AudioMixer(mic, new FakeSource(), m => logs.Add(m));
|
||||
mixer.Start();
|
||||
mic.Emit(new AudioSample(new[] { 0.5f }, 48000, 1));
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
mic.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Contains(logs, l => l.Contains("boom"));
|
||||
Assert.Equal(0f, last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopbackFailure_LogsButKeepsMic()
|
||||
{
|
||||
var loopback = new FakeSource();
|
||||
var logs = new List<string>();
|
||||
var mixer = new AudioMixer(new FakeSource(), loopback, m => logs.Add(m));
|
||||
mixer.Start();
|
||||
|
||||
var last = -1f;
|
||||
mixer.MicLevelChanged += l => last = l;
|
||||
loopback.Fail(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Contains(logs, l => l.Contains("boom"));
|
||||
Assert.Equal(-1f, last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_StopsAndDisposesSources()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Start();
|
||||
|
||||
mixer.Dispose();
|
||||
|
||||
Assert.True(mic.Disposed);
|
||||
Assert.True(loopback.Disposed);
|
||||
Assert.Equal(1, mic.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesEvents()
|
||||
{
|
||||
var mic = new FakeSource();
|
||||
var loopback = new FakeSource();
|
||||
var mixer = new AudioMixer(mic, loopback);
|
||||
mixer.Dispose();
|
||||
|
||||
var levels = new List<float>();
|
||||
mixer.MicLevelChanged += l => levels.Add(l);
|
||||
mic.Emit(new AudioSample(new[] { 1f }, 48000, 1));
|
||||
|
||||
Assert.Empty(levels);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioLevelMeterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstantSine_ConvergesToRms()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
var sample = new AudioSample(new[] { 0.5f, -0.5f, 0.5f, -0.5f }, 48000, 1);
|
||||
|
||||
float level = 0;
|
||||
for (var i = 0; i < 30; i++)
|
||||
level = meter.Push(sample);
|
||||
|
||||
Assert.InRange(level, 0.45f, 0.55f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Silence_DrivesTowardZero()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
var samples = new float[1024];
|
||||
var sample = new AudioSample(samples, 48000, 1);
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
meter.Push(sample);
|
||||
|
||||
Assert.Equal(0f, meter.Level, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySample_KeepsLevel()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
var before = meter.Level;
|
||||
|
||||
var level = meter.Push(new AudioSample(Array.Empty<float>(), 48000, 1));
|
||||
|
||||
Assert.Equal(before, level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ZerosLevel()
|
||||
{
|
||||
var meter = new AudioLevelMeter();
|
||||
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||
|
||||
meter.Reset();
|
||||
|
||||
Assert.Equal(0f, meter.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDisplay_IsLogarithmic()
|
||||
{
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
|
||||
|
||||
// -60 dBFS floor → 0, 0 dBFS → 1, and a decade (~-20 dB) is 1/3 up the bar.
|
||||
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
|
||||
Assert.Equal(0.5f, AudioLevelMeter.ToDisplay(0.0316f), 2);
|
||||
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f);
|
||||
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.32f, 0.34f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDisplay_ClampsAtFloor()
|
||||
{
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.0001f));
|
||||
Assert.Equal(0f, AudioLevelMeter.ToDisplay(-1f));
|
||||
}
|
||||
}
|
||||
|
||||
public class WaveToFloatTests
|
||||
{
|
||||
private static byte[] FloatSamples(params float[] values)
|
||||
{
|
||||
var bytes = new byte[values.Length * 4];
|
||||
Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IeeeFloat32_PreservesValues()
|
||||
{
|
||||
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(FloatSamples(0.25f, -0.5f, 1f), 12, format);
|
||||
|
||||
Assert.Equal(3, samples.Length);
|
||||
Assert.Equal(0.25f, samples[0], 4);
|
||||
Assert.Equal(-0.5f, samples[1], 4);
|
||||
Assert.Equal(1f, samples[2], 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pcm16_NormalizesToUnitRange()
|
||||
{
|
||||
var format = WaveFormat.CreateCustomFormat(WaveFormatEncoding.Pcm, 48000, 1, 48000 * 2, 2, 16);
|
||||
var bytes = new byte[] { 0x00, 0x00, 0xFF, 0x7F, 0x00, 0x80 };
|
||||
var samples = WaveToFloat.Convert(bytes, 6, format);
|
||||
|
||||
Assert.Equal(3, samples.Length);
|
||||
Assert.Equal(0f, samples[0], 4);
|
||||
Assert.Equal(1f, samples[1], 4);
|
||||
Assert.Equal(-1f, samples[2], 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncatedTrailingBytes_AreIgnored()
|
||||
{
|
||||
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var bytes = new byte[] { 0, 0, 0x80, 0x3F, 1, 2, 3 }; // 1 float + 3 stray bytes
|
||||
|
||||
var samples = WaveToFloat.Convert(bytes, bytes.Length, format);
|
||||
|
||||
Assert.Single(samples);
|
||||
Assert.Equal(1f, samples[0], 4);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -9,20 +12,28 @@ public class CameraManagerTests
|
||||
{
|
||||
private readonly List<string> _started;
|
||||
private readonly List<string> _stopped;
|
||||
private readonly VideoFrame? _pumpOnStart;
|
||||
private readonly string? _failOnStart;
|
||||
|
||||
public string DeviceId { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<string>? SourceFailed;
|
||||
|
||||
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped)
|
||||
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped,
|
||||
VideoFrame? pumpOnStart = null, string? failOnStart = null)
|
||||
{
|
||||
DeviceId = deviceId;
|
||||
_started = started;
|
||||
_stopped = stopped;
|
||||
_pumpOnStart = pumpOnStart;
|
||||
_failOnStart = failOnStart;
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
|
||||
_started.Add(DeviceId);
|
||||
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -33,29 +44,15 @@ public class CameraManagerTests
|
||||
}
|
||||
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
public void Fail(string message) => SourceFailed?.Invoke(message);
|
||||
}
|
||||
|
||||
private sealed class FailingFrameSource : ICameraFrameSource
|
||||
{
|
||||
public string DeviceId { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public bool Stopped;
|
||||
|
||||
public FailingFrameSource(string deviceId) => DeviceId = deviceId;
|
||||
|
||||
public Task StartAsync() => throw new InvalidOperationException("camera in use");
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
Stopped = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
}
|
||||
|
||||
// The refcount/coalescing tests model a camera whose frame pump is driven by
|
||||
// the test after acquire, so they skip the first-frame proof (TimeSpan.Zero);
|
||||
// the proof itself is exercised by the dedicated tests below.
|
||||
private static CameraManager CreateManager(List<string> started, List<string> stopped)
|
||||
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped));
|
||||
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped),
|
||||
null, TimeSpan.Zero);
|
||||
|
||||
private sealed class FakeEnumerator : ICameraEnumerator
|
||||
{
|
||||
@@ -93,7 +90,8 @@ public class CameraManagerTests
|
||||
FakeFrameSource? captured = null;
|
||||
var manager = new CameraManager(
|
||||
new FakeEnumerator(),
|
||||
id => captured = new FakeFrameSource(id, started, stopped));
|
||||
id => captured = new FakeFrameSource(id, started, stopped),
|
||||
null, TimeSpan.Zero);
|
||||
|
||||
await manager.AcquireAsync("dev1");
|
||||
var frame = new VideoFrame(2, 2, new byte[16]);
|
||||
@@ -105,7 +103,10 @@ public class CameraManagerTests
|
||||
[Fact]
|
||||
public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
|
||||
{
|
||||
var manager = new CameraManager(new FakeEnumerator(), id => new FailingFrameSource(id));
|
||||
var manager = new CameraManager(
|
||||
new FakeEnumerator(),
|
||||
id => new FakeFrameSource(id, new List<string>(), new List<string>(), failOnStart: "camera in use"),
|
||||
null, TimeSpan.Zero);
|
||||
string? failedDevice = null;
|
||||
manager.CameraFailed += (device, _) => failedDevice = device;
|
||||
|
||||
@@ -119,4 +120,67 @@ public class CameraManagerTests
|
||||
var manager = CreateManager(new List<string>(), new List<string>());
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
// ─── First-frame proof (the integration test for this change) ───
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed()
|
||||
{
|
||||
var stopped = new List<string>();
|
||||
var manager = new CameraManager(
|
||||
new FakeEnumerator(),
|
||||
id => new FakeFrameSource(id, new List<string>(), stopped),
|
||||
null, TimeSpan.FromMilliseconds(150));
|
||||
string? failedDevice = null;
|
||||
string? failedMessage = null;
|
||||
manager.CameraFailed += (device, message) =>
|
||||
{
|
||||
failedDevice = device;
|
||||
failedMessage = message;
|
||||
};
|
||||
|
||||
// The reader "starts" fine but never delivers a frame — the exact
|
||||
// silent-empty-box scenario. Must fail, be reported, and be rolled back.
|
||||
Assert.False(await manager.AcquireAsync("dev1"));
|
||||
Assert.Equal("dev1", failedDevice);
|
||||
Assert.Contains("no frames", failedMessage);
|
||||
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||
Assert.Contains("dev1", stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout()
|
||||
{
|
||||
var manager = new CameraManager(
|
||||
new FakeEnumerator(),
|
||||
id => new FakeFrameSource(id, new List<string>(), new List<string>(),
|
||||
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
||||
null, TimeSpan.FromSeconds(10));
|
||||
|
||||
var started = await manager.AcquireAsync("dev1");
|
||||
|
||||
Assert.True(started);
|
||||
Assert.NotNull(manager.GetLatestFrame("dev1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack()
|
||||
{
|
||||
var stopped = new List<string>();
|
||||
FakeFrameSource? source = null;
|
||||
var manager = new CameraManager(
|
||||
new FakeEnumerator(),
|
||||
id => source = new FakeFrameSource(id, new List<string>(), stopped,
|
||||
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
||||
null, TimeSpan.Zero);
|
||||
string? failedMessage = null;
|
||||
manager.CameraFailed += (_, message) => failedMessage = message;
|
||||
|
||||
Assert.True(await manager.AcquireAsync("dev1"));
|
||||
source!.Fail("capture failed (0x8007001F): device not available");
|
||||
|
||||
Assert.Contains("device not available", failedMessage);
|
||||
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||
Assert.Contains("dev1", stopped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The FFmpeg subprocess encoder (TASK 4 ship step 3). The integration test drives
|
||||
/// the full lifecycle against fakes: encoder probe → subprocess start with the right
|
||||
/// args → raw frames into stdin → stderr progress parsed into health → graceful stop.
|
||||
/// The units pin down the pure pieces (args, progress parser, encoder picker) and
|
||||
/// the failure edges. No real ffmpeg binary, no network.
|
||||
/// </summary>
|
||||
public class FfmpegEncoderTests
|
||||
{
|
||||
private sealed class QueuedReader : TextReader
|
||||
{
|
||||
private readonly Channel<string?> _channel = Channel.CreateUnbounded<string?>();
|
||||
public void Enqueue(string s) => _channel.Writer.TryWrite(s);
|
||||
public void Complete() => _channel.Writer.TryComplete();
|
||||
public override string? ReadLine() => ReadLineAsync().GetAwaiter().GetResult();
|
||||
|
||||
public override async Task<string?> ReadLineAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _channel.Reader.ReadAsync().AsTask().ConfigureAwait(false);
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
return null; // stream EOF — the process exited and the pipe closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEncoderProcess : IEncoderProcess
|
||||
{
|
||||
public ProcessStartInfo? StartInfo { get; private set; }
|
||||
public MemoryStream Stdin { get; } = new();
|
||||
public Stream StandardInput => Stdin;
|
||||
public TextReader StandardOutput { get; }
|
||||
public bool HasExited { get; private set; }
|
||||
public int ExitCode { get; private set; }
|
||||
public bool Killed { get; private set; }
|
||||
public bool Started { get; private set; }
|
||||
|
||||
private readonly TaskCompletionSource _exit =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly QueuedReader _error = new();
|
||||
|
||||
public FakeEncoderProcess(string probeOutput = "")
|
||||
=> StandardOutput = new StringReader(probeOutput);
|
||||
|
||||
public TextReader StandardError => _error;
|
||||
public void EnqueueStderr(string line) => _error.Enqueue(line);
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
StartInfo = startInfo;
|
||||
Started = true;
|
||||
}
|
||||
|
||||
public void SignalExit(int code = 0)
|
||||
{
|
||||
ExitCode = code;
|
||||
HasExited = true;
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
Killed = true;
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exit.Task;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_error.Complete();
|
||||
_exit.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEncoderProcessFactory
|
||||
{
|
||||
private readonly Queue<FakeEncoderProcess> _processes = new();
|
||||
public void Return(FakeEncoderProcess p) => _processes.Enqueue(p);
|
||||
public FakeEncoderProcess Create() => _processes.Dequeue();
|
||||
}
|
||||
|
||||
private sealed class StubLocator : IFfmpegLocator
|
||||
{
|
||||
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(@"C:\tools\ffmpeg.exe");
|
||||
}
|
||||
|
||||
private static byte[] BgraFrame(int w, int h, byte b, byte g, byte r)
|
||||
{
|
||||
var bytes = new byte[w * h * 4];
|
||||
for (var i = 0; i < bytes.Length; i += 4)
|
||||
{
|
||||
bytes[i] = b;
|
||||
bytes[i + 1] = g;
|
||||
bytes[i + 2] = r;
|
||||
bytes[i + 3] = 255;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_ProbesEncoder_FeedsFrames_ParsesHealth_StopsGracefully()
|
||||
{
|
||||
const string encoders =
|
||||
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
|
||||
" V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||
var probe = new FakeEncoderProcess(encoders);
|
||||
var encoderProc = new FakeEncoderProcess();
|
||||
var factory = new FakeEncoderProcessFactory();
|
||||
factory.Return(probe);
|
||||
factory.Return(encoderProc);
|
||||
|
||||
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||
|
||||
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
encoder.HealthUpdated += (_, h) => health.TrySetResult(h);
|
||||
|
||||
var options = new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc-xyz",
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
Fps = 60,
|
||||
BitrateKbps = 8000,
|
||||
};
|
||||
|
||||
await encoder.StartAsync(options);
|
||||
|
||||
Assert.True(probe.Started, "the -encoders probe must run");
|
||||
Assert.True(encoderProc.Started, "the encoder subprocess must start");
|
||||
Assert.Equal(@"C:\tools\ffmpeg.exe", encoderProc.StartInfo!.FileName);
|
||||
var args = encoderProc.StartInfo.ArgumentList.ToArray();
|
||||
var c = Array.IndexOf(args, "-c:v");
|
||||
Assert.True(
|
||||
args[c + 1] == "h264_nvenc",
|
||||
"hardware NVENC must be preferred over the listed openh264");
|
||||
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
|
||||
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
|
||||
|
||||
encoderProc.EnqueueStderr(
|
||||
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
|
||||
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(4000.1, h.CurrentBitrate, 1);
|
||||
Assert.Equal(59.9, h.FPS, 1);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), h.StreamDuration);
|
||||
|
||||
encoderProc.SignalExit();
|
||||
await encoder.StopAsync();
|
||||
Assert.False(encoder.IsRunning);
|
||||
Assert.False(encoderProc.Killed, "graceful stop must not kill the process");
|
||||
Assert.Equal(StreamStatus.Offline, h.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WithoutRtmpUrl_Throws()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitFrame_WhenNotRunning_Throws()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => encoder.SubmitFrameAsync(new VideoFrame(2, 2, new byte[16])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_WithoutStart_IsNoop()
|
||||
{
|
||||
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||
await encoder.StopAsync();
|
||||
Assert.False(encoder.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessDeath_RaisesFailed()
|
||||
{
|
||||
var encoderProc = new FakeEncoderProcess();
|
||||
var factory = new FakeEncoderProcessFactory();
|
||||
factory.Return(new FakeEncoderProcess());
|
||||
factory.Return(encoderProc);
|
||||
|
||||
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
encoder.ProcessFailed += (_, msg) => failed.TrySetResult(msg);
|
||||
|
||||
await encoder.StartAsync(new EncoderOptions { RtmpUrl = "rtmp://x/y" });
|
||||
encoderProc.SignalExit(1);
|
||||
var msg = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("1", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_Gop_IsFpsTimesFour()
|
||||
{
|
||||
var options = new EncoderOptions { Fps = 60 };
|
||||
Assert.Equal(240, options.GopSize);
|
||||
|
||||
var args = FfmpegArgs.Build(options, "libopenh264").ToArray();
|
||||
var g = Array.IndexOf(args, "-g");
|
||||
Assert.Equal("240", args[g + 1]);
|
||||
var keyint = Array.IndexOf(args, "-keyint_min");
|
||||
Assert.Equal("240", args[keyint + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_IncludeInputOutputAndCompliance()
|
||||
{
|
||||
var args = FfmpegArgs.Build(
|
||||
new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
Fps = 60,
|
||||
BitrateKbps = 8000,
|
||||
},
|
||||
"libopenh264").ToArray();
|
||||
|
||||
Assert.Contains("-f", args);
|
||||
Assert.Contains("rawvideo", args);
|
||||
Assert.Contains("pipe:0", args);
|
||||
Assert.Contains("1920x1080", args);
|
||||
Assert.Contains("anullsrc=channel_layout=stereo:sample_rate=48000", args);
|
||||
Assert.Contains("-sc_threshold", args);
|
||||
Assert.Contains("-bf", args);
|
||||
Assert.Contains("yuv420p", args);
|
||||
Assert.Contains("aac", args);
|
||||
Assert.Contains("flv", args);
|
||||
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressParser_ParsesRealStatsLine()
|
||||
{
|
||||
var p = FfmpegProgressParser.TryParse(
|
||||
"frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x");
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(123, p.Value.Frame);
|
||||
Assert.Equal(59.9, p.Value.Fps, 1);
|
||||
Assert.Equal(4000.1, p.Value.BitrateKbps, 1);
|
||||
Assert.Equal(2.04, p.Value.Duration.TotalSeconds, 2);
|
||||
Assert.Equal(1024 * 1024, p.Value.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressParser_IgnoresBannerAndErrors()
|
||||
{
|
||||
Assert.Null(FfmpegProgressParser.TryParse("ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers"));
|
||||
Assert.Null(FfmpegProgressParser.TryParse("Error while opening encoder for output stream #0:0"));
|
||||
Assert.Null(FfmpegProgressParser.TryParse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncoderPicker_PrefersHardware_NeverLibx264()
|
||||
{
|
||||
var listing =
|
||||
" V..... libx264 libx264 H.264 / AVC (codec h264)\n" +
|
||||
" V..... libopenh264 OpenH264 H.264 (codec h264)\n" +
|
||||
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n";
|
||||
Assert.Equal("h264_nvenc", FfmpegEncoderPicker.Pick(listing));
|
||||
|
||||
var onlyGpl = " V..... libx264 libx264 H.264 / AVC (codec h264)\n";
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(onlyGpl));
|
||||
|
||||
var software = " V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(software));
|
||||
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
|
||||
/// first, then the cache in the tools directory, then pulling the pinned BtbN
|
||||
/// LGPL-shared zip. The integration test drives the full ladder against a temp
|
||||
/// tools dir and a fake downloader that returns a real in-memory zip; the units
|
||||
/// pin down the failure and edge cases. No network, no real binary.
|
||||
/// </summary>
|
||||
public class FfmpegLocatorTests
|
||||
{
|
||||
private static byte[] MakeZip(
|
||||
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
|
||||
string[]? dllPaths = null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(exePath);
|
||||
using (var writer = new StreamWriter(entry.Open()))
|
||||
writer.Write("dummy ffmpeg binary");
|
||||
foreach (var dll in dllPaths ?? Array.Empty<string>())
|
||||
{
|
||||
var dllEntry = archive.CreateEntry(dll);
|
||||
using var dllWriter = new StreamWriter(dllEntry.Open());
|
||||
dllWriter.Write("dummy dll");
|
||||
}
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static string TempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private sealed class RecordingDownloader
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public byte[] Payload { get; set; } = MakeZip();
|
||||
public Exception? Error { get; set; }
|
||||
|
||||
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
if (Error != null) throw Error;
|
||||
return Task.FromResult(Payload);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_Integration_FullDecisionLadder()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
// 1. PATH hit wins, downloader never invoked.
|
||||
var pathDir = TempDir();
|
||||
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
|
||||
File.WriteAllText(pathExe, "user's ffmpeg");
|
||||
var downloader = new RecordingDownloader();
|
||||
|
||||
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
|
||||
Assert.Equal(pathExe, await fromPath.LocateAsync());
|
||||
Assert.Equal(0, downloader.Calls);
|
||||
|
||||
// 2. Cache hit skips the network.
|
||||
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
File.WriteAllText(cached, "cached ffmpeg");
|
||||
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
Assert.Equal(cached, await fromCache.LocateAsync());
|
||||
Assert.Equal(0, downloader.Calls);
|
||||
|
||||
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
|
||||
// second call serves the cache without re-downloading.
|
||||
File.Delete(cached);
|
||||
var coldTools = TempDir();
|
||||
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
|
||||
var resolved = await cold.LocateAsync();
|
||||
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
Assert.True(new FileInfo(resolved).Length > 0);
|
||||
Assert.Equal(resolved, await cold.LocateAsync());
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_ZeroByteCache_IsRefreshed()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
|
||||
var downloader = new RecordingDownloader();
|
||||
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
var resolved = await locator.LocateAsync();
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
Assert.True(new FileInfo(resolved).Length > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
var downloader = new RecordingDownloader
|
||||
{
|
||||
Payload = MakeZip(dllPaths:
|
||||
[
|
||||
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
|
||||
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
|
||||
])
|
||||
};
|
||||
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
var resolved = await locator.LocateAsync();
|
||||
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
|
||||
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_EmptyPayload_Throws()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_DownloaderFailure_Propagates()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => locator.LocateAsync());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,14 @@ public class LayoutStorePersistenceTests
|
||||
Width = 480,
|
||||
Height = 270,
|
||||
});
|
||||
store.Save(new[] { scene }, webcam);
|
||||
store.Save(new[] { scene }, webcam, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
var config = Assert.Single(reloaded[0].Elements);
|
||||
Assert.IsType<WebcamSceneConfig>(config);
|
||||
|
||||
reloaded[0].Elements.RemoveAt(0);
|
||||
store.Save(reloaded, store.Webcam);
|
||||
store.Save(reloaded, store.Webcam, null);
|
||||
|
||||
var afterDelete = store.Load();
|
||||
Assert.Empty(afterDelete[0].Elements);
|
||||
@@ -74,7 +74,7 @@ public class LayoutStorePersistenceTests
|
||||
};
|
||||
var scene = new Scene { Name = "Starting" };
|
||||
scene.Elements.Add(backdrop);
|
||||
store.Save(new[] { scene }, null);
|
||||
store.Save(new[] { scene }, null, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
|
||||
@@ -102,7 +102,7 @@ public class LayoutStorePersistenceTests
|
||||
using var store = new LayoutStore(path);
|
||||
var on = new Scene { Name = "Live", HasBackdrop = true };
|
||||
var off = new Scene { Name = "Ending", HasBackdrop = false };
|
||||
store.Save(new[] { on, off }, null);
|
||||
store.Save(new[] { on, off }, null, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
|
||||
@@ -216,7 +216,7 @@ public class LayoutStorePersistenceTests
|
||||
RectWidth = 480,
|
||||
RectHeight = 270,
|
||||
});
|
||||
store.Save(new[] { scene }, webcam);
|
||||
store.Save(new[] { scene }, webcam, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that spin up the real WPF App need exactly one Application instance per
|
||||
/// AppDomain (WPF enforces it) — so they share this serial collection and a
|
||||
/// single App created on one dedicated STA thread. Marshal test bodies onto it
|
||||
/// via <see cref="RealAppHost.Run"/>, never `new App()` per test.
|
||||
/// </summary>
|
||||
[CollectionDefinition("RealApp", DisableParallelization = true)]
|
||||
public sealed class RealAppCollection : ICollectionFixture<RealAppHost>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Owns the one-and-only WPF App on a dedicated STA thread.</summary>
|
||||
public sealed class RealAppHost : IDisposable
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public RealAppHost()
|
||||
{
|
||||
Exception? boot = null;
|
||||
Dispatcher? dispatcher = null;
|
||||
_thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
dispatcher = Dispatcher.CurrentDispatcher;
|
||||
Dispatcher.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
boot = ex;
|
||||
}
|
||||
});
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
while (dispatcher == null && boot == null)
|
||||
Thread.Sleep(5);
|
||||
if (boot != null)
|
||||
throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
|
||||
_dispatcher = dispatcher!;
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="action"/> on the App's STA thread and rethrows any failure.</summary>
|
||||
public void Run(Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
_dispatcher.Invoke(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
if (failure != null)
|
||||
throw failure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_dispatcher.InvokeShutdown();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The App never ran its message loop — shutdown is best-effort.
|
||||
}
|
||||
_thread.Join(2000);
|
||||
}
|
||||
}
|
||||
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
|
||||
/// (doesn't fall through and deselect the source) and whether the round clip
|
||||
/// renders as a circle rather than an oval.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class RoundClipInteractionTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public RoundClipInteractionTests(RealAppHost app)
|
||||
{
|
||||
_app = app;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
|
||||
{
|
||||
Exception? failure = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (failure != null)
|
||||
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
|
||||
// Never let the real MainWindow read/write the user's actual layout DB —
|
||||
// Shutdown() saves the layout, which would persist these test sources
|
||||
// over the real ones. Point it at a throwaway temp DB instead.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
|
||||
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
|
||||
/// test composites a full scene (backdrop + round webcam + images + mirror + border)
|
||||
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
|
||||
/// and upscale. Frames are pushed by the test — no capture managers involved.
|
||||
/// </summary>
|
||||
public class SceneCompositorTests
|
||||
{
|
||||
public static VideoFrame Solid(int w, int h, byte r, byte g, byte b)
|
||||
{
|
||||
var pixels = new byte[w * h * 4];
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = b;
|
||||
pixels[i + 1] = g;
|
||||
pixels[i + 2] = r;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
return new VideoFrame(w, h, pixels);
|
||||
}
|
||||
|
||||
/// <summary>Left half = leftColor, right half = rightColor.</summary>
|
||||
private static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb)
|
||||
{
|
||||
var pixels = new byte[w * h * 4];
|
||||
var half = w / 2;
|
||||
for (var y = 0; y < h; y++)
|
||||
{
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
var i = (y * w + x) * 4;
|
||||
pixels[i] = x < half ? lb : rb;
|
||||
pixels[i + 1] = x < half ? lg : rg;
|
||||
pixels[i + 2] = x < half ? lr : rr;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return new VideoFrame(w, h, pixels);
|
||||
}
|
||||
|
||||
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 void Composite_FullScene_MasterPixels()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0); // backdrop
|
||||
var green = Solid(1280, 720, 0, 255, 0); // webcam
|
||||
var split = Split(100, 100, 0, 255, 255, 255, 0, 255); // image: left cyan, right magenta
|
||||
|
||||
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
|
||||
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
|
||||
var imageA = new Source { Type = SourceType.Image, X = 1200, Y = 600, Width = 200, Height = 200 };
|
||||
var imageB = new Source
|
||||
{
|
||||
Type = SourceType.Image, X = 500, Y = 700, Width = 100, Height = 100, IsMirrored = true,
|
||||
BorderColor = "#ffffff", BorderOpacity = 1, BorderWidth = 4,
|
||||
};
|
||||
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(backdrop);
|
||||
scene.Elements.Add(roundWebcam);
|
||||
scene.Elements.Add(imageA);
|
||||
scene.Elements.Add(imageB);
|
||||
|
||||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||||
{
|
||||
WebcamSceneConfig => green,
|
||||
Source { IsBackdrop: true } => red,
|
||||
Source { Type: SourceType.Image } => split,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||||
OutputWidth = 1920, OutputHeight = 1080,
|
||||
};
|
||||
|
||||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||||
|
||||
Assert.Equal(1920, output.Width);
|
||||
Assert.Equal(1080, output.Height);
|
||||
|
||||
// backdrop at the frame corner
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
// round webcam: center is the (square-cropped) webcam feed
|
||||
AssertColor(output, 250, 250, 0, 255, 0);
|
||||
// round webcam: element-square corner is OUTSIDE the circle -> backdrop shows
|
||||
AssertColor(output, 101, 101, 255, 0, 0);
|
||||
// imageA (unmirrored): left half cyan, right half magenta
|
||||
AssertColor(output, 1220, 700, 0, 255, 255);
|
||||
AssertColor(output, 1380, 700, 255, 0, 255);
|
||||
// imageB (mirrored): halves swap — element left shows the source's right (magenta)
|
||||
AssertColor(output, 520, 750, 255, 0, 255);
|
||||
AssertColor(output, 580, 750, 0, 255, 255);
|
||||
// imageB border: white ring at the top edge (drawn over the content)
|
||||
AssertColor(output, 550, 700, 255, 255, 255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0);
|
||||
var green = Solid(1280, 720, 0, 255, 0);
|
||||
|
||||
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
|
||||
var roundWebcam = new WebcamSceneConfig { X = 656, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(backdrop);
|
||||
scene.Elements.Add(roundWebcam);
|
||||
|
||||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||||
{
|
||||
WebcamSceneConfig => green,
|
||||
Source { IsBackdrop: true } => red,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 607, SourceRectHeight = 1080,
|
||||
OutputWidth = 1080, OutputHeight = 1920,
|
||||
};
|
||||
|
||||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||||
|
||||
Assert.Equal(1080, output.Width);
|
||||
Assert.Equal(1920, output.Height);
|
||||
// top-left of the crop is pure backdrop (webcam starts at crop y=100, inset from the corner)
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
|
||||
AssertColor(output, 267, 444, 0, 255, 0);
|
||||
// far from the webcam, still backdrop
|
||||
AssertColor(output, 978, 1688, 255, 0, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Composite_WithFlash_BlendsOverContent()
|
||||
{
|
||||
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);
|
||||
|
||||
// flash: a semi-transparent white pixel at the center of a master-sized frame
|
||||
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||||
var ci = (1080 / 2 * 1920 + 1920 / 2) * 4;
|
||||
flash.BgraPixels[ci] = 255;
|
||||
flash.BgraPixels[ci + 1] = 255;
|
||||
flash.BgraPixels[ci + 2] = 255;
|
||||
flash.BgraPixels[ci + 3] = 64; // ~25% alpha
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||||
OutputWidth = 1920, OutputHeight = 1080,
|
||||
};
|
||||
|
||||
var output = new SceneCompositor().Render(scene, _ => red, flash, options);
|
||||
|
||||
// red lightened by 25% white: ~(255, 63, 63)
|
||||
AssertColor(output, 960, 540, 255, 64, 64);
|
||||
// untouched corner stays pure red
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0);
|
||||
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(backdrop);
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||||
OutputWidth = 1920, OutputHeight = 1080,
|
||||
};
|
||||
var compositor = new SceneCompositor();
|
||||
|
||||
// master-sized overlay: opaque green at (0,0), 50%-white at the center
|
||||
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||||
var g = 0;
|
||||
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
|
||||
var w = (540 * 1920 + 960) * 4;
|
||||
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
|
||||
|
||||
// flash: opaque magenta at (0,0) — the bar must cover it
|
||||
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||||
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
|
||||
|
||||
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
|
||||
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
|
||||
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
|
||||
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
|
||||
|
||||
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
|
||||
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
|
||||
AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
|
||||
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
|
||||
}
|
||||
}
|
||||
|
||||
public class StretchMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void UniformToFill_SameAspect_Is_Exact_Fit_With_No_Offset()
|
||||
{
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(400, 300, 800, 600);
|
||||
Assert.Equal(0.5f, scale, 3);
|
||||
Assert.Equal(0f, ox, 3);
|
||||
Assert.Equal(0f, oy, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UniformToFill_WiderSource_Crops_And_Centers_Vertically()
|
||||
{
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(400, 200, 800, 600);
|
||||
Assert.Equal(0.5f, scale, 3);
|
||||
Assert.Equal(0f, ox, 3);
|
||||
Assert.Equal(-50f, oy, 3); // drawn 400x300 into 400x200 -> 50px crop top and bottom
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BilinearScale_SameSize_ReturnsTheInput()
|
||||
{
|
||||
var src = SceneCompositorTests.Solid(4, 4, 10, 20, 30);
|
||||
Assert.Same(src, StretchMath.BilinearScale(src, 4, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BilinearScale_Downscales_To_Target_Size()
|
||||
{
|
||||
var src = SceneCompositorTests.Solid(16, 16, 0, 255, 0);
|
||||
var scaled = StretchMath.BilinearScale(src, 8, 8);
|
||||
Assert.Equal(8, scaled.Width);
|
||||
Assert.Equal(8, scaled.Height);
|
||||
var i = 0;
|
||||
Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,28 @@ public class ScreenCaptureManagerTests
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
|
||||
{
|
||||
FakeScreenSource? captured = null;
|
||||
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key));
|
||||
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0"));
|
||||
|
||||
Assert.True(await manager.AcquireAsync("monitor:0"));
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0")); // nothing pumped yet
|
||||
|
||||
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
|
||||
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
|
||||
captured!.Pump(first);
|
||||
captured.Pump(second);
|
||||
|
||||
Assert.Same(second, manager.GetLatestFrame("monitor:0"));
|
||||
|
||||
await manager.ReleaseAsync("monitor:0");
|
||||
Assert.Null(manager.GetLatestFrame("monitor:0"));
|
||||
}
|
||||
|
||||
// The one integration test for this branch: one target creates one shared
|
||||
// WriteableBitmap, published once, and back-to-back frames coalesce to the
|
||||
// latest (a single pending UI copy per session).
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialBarTests
|
||||
{
|
||||
private static string TempDbPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-test-{System.Guid.NewGuid():N}.db");
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(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]
|
||||
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
|
||||
{
|
||||
var path = TempDbPath();
|
||||
try
|
||||
{
|
||||
var socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = SocialBarPosition.Top,
|
||||
BarEnabled = false,
|
||||
};
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = "mychannel",
|
||||
ProfileUrl = "https://www.youtube.com/@mychannel",
|
||||
});
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Twitch,
|
||||
Handle = "streamer123",
|
||||
ProfileUrl = "https://www.twitch.tv/streamer123",
|
||||
});
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
});
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.HasSocialBar = true;
|
||||
store.Save(new[] { scene }, null, socials);
|
||||
}
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
var scenes = store.Load();
|
||||
Assert.NotNull(store.Socials);
|
||||
Assert.Equal(3, store.Socials!.Entries.Count);
|
||||
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||
Assert.Equal("mychannel", store.Socials.Entries[0].Handle);
|
||||
Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl);
|
||||
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||
Assert.Equal(SocialService.Fediverse, store.Socials.Entries[2].Service);
|
||||
Assert.Equal("@gramps@llamachile.tube", store.Socials.Entries[2].Handle);
|
||||
Assert.Equal("peertube", store.Socials.Entries[2].FediverseSoftware);
|
||||
Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition);
|
||||
Assert.False(store.Socials.BarEnabled);
|
||||
Assert.True(scenes[0].HasSocialBar);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialsConfig_EmptyEntries_NotPersisted()
|
||||
{
|
||||
var path = TempDbPath();
|
||||
try
|
||||
{
|
||||
var socials = new SocialsConfig();
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.Save(new[] { new Scene { Name = "Live" } }, null, socials);
|
||||
}
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
Assert.Null(store.Socials);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialsConfig_DefaultBarEnabled_IsOn()
|
||||
{
|
||||
Assert.True(new SocialsConfig().BarEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_CanonicalUrlFor_AllServices()
|
||||
{
|
||||
Assert.Equal("https://www.youtube.com/@test", SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, "test"));
|
||||
Assert.Equal("https://www.youtube.com/@test", SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, "@test"));
|
||||
Assert.Equal("https://x.com/user", SocialServiceIcons.CanonicalUrlFor(SocialService.X, "user"));
|
||||
Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Link, "@user@site.tld"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Fediverse, "@user@site.tld"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_LogoData_AllServices()
|
||||
{
|
||||
foreach (var service in Enum.GetValues<SocialService>())
|
||||
Assert.False(string.IsNullOrWhiteSpace(SocialServiceIcons.LogoDataFor(service)), service.ToString());
|
||||
Assert.NotEmpty(SocialServiceIcons.LockedIconData);
|
||||
Assert.NotEmpty(SocialServiceIcons.DoNotIconData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.YouTube), SocialServiceIcons.LogoDataFor(SocialService.Twitch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_LogoDataForFediverse_KnownAndUnknownSoftware()
|
||||
{
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("PeerTube"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), SocialServiceIcons.LogoDataForFediverse("MASTODON"));
|
||||
Assert.NotEqual(
|
||||
SocialServiceIcons.LogoDataForFediverse("peertube"),
|
||||
SocialServiceIcons.LogoDataForFediverse("mastodon"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse(null), SocialServiceIcons.LogoDataForFediverse("gotosocial"));
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialEntry_FediverseLogo_ComesFromSoftwareName()
|
||||
{
|
||||
var entry = new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
};
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.Link), entry.LogoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_ByDomain()
|
||||
{
|
||||
Assert.Equal(SocialService.YouTube, SocialServiceIcons.DetectService("youtube.com/@handle").service);
|
||||
Assert.Equal(SocialService.X, SocialServiceIcons.DetectService("https://x.com/user").service);
|
||||
Assert.Equal(SocialService.Instagram, SocialServiceIcons.DetectService("instagram.com/user").service);
|
||||
var fediverse = SocialServiceIcons.DetectService("@user@instance.tube");
|
||||
Assert.Equal(SocialService.Fediverse, fediverse.service);
|
||||
Assert.Equal("@user@instance.tube", fediverse.handle);
|
||||
Assert.Equal("https://instance.tube/@user", fediverse.url);
|
||||
Assert.Equal(SocialService.GitHub, SocialServiceIcons.DetectService("https://github.com/dev").service);
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("example.com/path").service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_BareHandle_FallsBackToWebsite()
|
||||
{
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("justaname").service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialValidatorTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
|
||||
public StubHandler(HttpResponseMessage response) : this(_ => response) { }
|
||||
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return Task.FromCanceled<HttpResponseMessage>(ct);
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpSocialValidator Validator(HttpMessageHandler handler)
|
||||
=> new(new HttpClient(handler));
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_BuildsHostUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://site.tld/@user", result.ProfileUrl);
|
||||
Assert.Equal("@user@site.tld", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_FetchesInstanceSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(RespondFediverse("peertube")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@gramps@llamachile.tube", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("peertube", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!;
|
||||
if (uri.Host == "site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath == "/")
|
||||
{
|
||||
// Identity domain's default app redirects to the real instance.
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://social.site.tld/"),
|
||||
};
|
||||
}
|
||||
if (uri.AbsolutePath.StartsWith("/@"))
|
||||
return new HttpResponseMessage(HttpStatusCode.OK); // profile validates
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound); // nodeinfo SSO-blocked
|
||||
}
|
||||
if (uri.Host == "social.site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath.StartsWith("/.well-known"))
|
||||
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://social.site.tld/nodeinfo/2.0" } } });
|
||||
if (uri.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("mastodon", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_NodeInfoUnavailable_StillSucceeds()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
request.RequestUri!.AbsolutePath.StartsWith("/.well-known")
|
||||
? new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains()
|
||||
{
|
||||
// The identity domain is a silent landing page (no nodeinfo, no redirect);
|
||||
// the real instance lives on mastodon.<domain> — the probe must find it.
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
{
|
||||
var host = request.RequestUri!.Host;
|
||||
if (host == "mastodon.llamachile.tube")
|
||||
{
|
||||
if (request.RequestUri.AbsolutePath.StartsWith("/.well-known"))
|
||||
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } });
|
||||
if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}));
|
||||
|
||||
var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None);
|
||||
|
||||
Assert.Equal("mastodon", software);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
|
||||
var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None);
|
||||
|
||||
Assert.Null(software);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var request = 0;
|
||||
var validator = Validator(new StubHandler(_ =>
|
||||
{
|
||||
if (++request >= 2) cts.Cancel();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestMessage, HttpResponseMessage> RespondFediverse(string software)
|
||||
{
|
||||
return request =>
|
||||
{
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/.well-known"))
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
links = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
rel = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
href = $"https://{request.RequestUri.Host}/nodeinfo/2.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
{
|
||||
return Json(new { software = new { name = software, version = "1.0.0" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(object payload)
|
||||
=> new(HttpStatusCode.OK) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload)) };
|
||||
|
||||
[Fact]
|
||||
public async Task KnownServiceHandle_CanonicalUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://x.com/creator", result.ProfileUrl);
|
||||
Assert.Equal("creator", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotFound_IsRejected()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "doesnotexist", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("NotFound", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redirect_CountsAsExists()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.Found)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.GitHub, "dev", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionFailure_IsFriendlyError()
|
||||
{
|
||||
var validator = Validator(new StubHandler(_ => throw new HttpRequestException("boom")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "user", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Couldn't reach", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledToken_AbortsRequest()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialsDialogViewModelTests
|
||||
{
|
||||
private sealed class FakeValidator : ISocialValidator
|
||||
{
|
||||
public int LookupCount { get; private set; }
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>("mastodon");
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
LookupCount++;
|
||||
if (handleOrUrl.StartsWith('@') && handleOrUrl.IndexOf('@', 1) > 0)
|
||||
{
|
||||
var at = handleOrUrl.IndexOf('@', 1);
|
||||
var user = handleOrUrl[1..at];
|
||||
var domain = handleOrUrl[(at + 1)..];
|
||||
return Task.FromResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = handleOrUrl,
|
||||
ProfileUrl = $"https://{domain}/@{user}",
|
||||
FediverseSoftware = "peertube",
|
||||
});
|
||||
}
|
||||
var handle = handleOrUrl.Trim().TrimStart('@');
|
||||
if (handle == "doesnotexist")
|
||||
return Task.FromResult(new SocialLookupResult { Error = "404 for 'doesnotexist'." });
|
||||
return Task.FromResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = handle,
|
||||
ProfileUrl = SocialServiceIcons.CanonicalUrlFor(service, handle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds the lookup open so a test can cancel mid-flight and then
|
||||
/// resolve it — proving a dismissed dialog never applies the result.</summary>
|
||||
private sealed class BlockingValidator : ISocialValidator
|
||||
{
|
||||
public TaskCompletionSource<SocialLookupResult> Gate { get; } = new();
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
=> Gate.Task;
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class SignInFake
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public Task<YouTubeChannel?> Next()
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult<YouTubeChannel?>(
|
||||
new YouTubeChannel { ChannelId = "UCabc", DisplayName = "My Channel" });
|
||||
}
|
||||
}
|
||||
|
||||
private int _signOutCalls;
|
||||
private Task SignOut() { _signOutCalls++; return Task.CompletedTask; }
|
||||
|
||||
private SocialsDialogViewModel Create(
|
||||
ISocialValidator? validator = null,
|
||||
SignInFake? signIn = null,
|
||||
YouTubeChannel? account = null,
|
||||
bool isPremium = false,
|
||||
SocialsConfig? current = null)
|
||||
{
|
||||
return new SocialsDialogViewModel(
|
||||
validator ?? new FakeValidator(),
|
||||
signIn != null ? signIn.Next : () => Task.FromResult<YouTubeChannel?>(null),
|
||||
SignOut,
|
||||
account,
|
||||
isPremium,
|
||||
current);
|
||||
}
|
||||
|
||||
// ── ONE integration test: the full dialog-VM flow with fakes ──
|
||||
|
||||
[Fact]
|
||||
public async Task FullFlow_Freemium_SignInAddDeleteSignOutPersistence()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var signIn = new SignInFake();
|
||||
var vm = Create(validator, signIn);
|
||||
|
||||
// Gate: signed out, freemium → row 0 is the sign-in prompt, rows 2-5 locked.
|
||||
Assert.True(vm.ShowSignInBanner);
|
||||
Assert.Equal(6, vm.Slots.Count);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
Assert.False(vm.Slots[0].IsLocked);
|
||||
Assert.False(vm.Slots[1].IsLocked);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
Assert.True(vm.Slots[5].IsLocked);
|
||||
Assert.True(vm.CanSave);
|
||||
|
||||
// Row 1: add a validated X handle. Save is blocked while input is unvalidated.
|
||||
vm.StartEdit(1);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
Assert.False(vm.CanSave);
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.Equal(SocialService.X, vm.Slots[1].Service);
|
||||
Assert.Equal("creator", vm.Slots[1].Handle);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.CanSave);
|
||||
|
||||
// Rows 2-5 are locked on the free tier — starting an edit there is blocked.
|
||||
vm.StartEdit(2);
|
||||
Assert.False(vm.Slots[2].IsEditing);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
// Delete the X entry: the slot empties and unlocks nothing else.
|
||||
await vm.DeleteSlotAsync(1);
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsLocked);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
// Delete row 0 while signed out is a no-op.
|
||||
await vm.DeleteSlotAsync(0);
|
||||
Assert.Equal(0, _signOutCalls);
|
||||
|
||||
// Sign in: row 0 fills with the channel; the X slot is still free.
|
||||
vm.SignInCommand.Execute(null);
|
||||
Assert.Equal(1, signIn.Calls);
|
||||
Assert.True(vm.IsSignedIn);
|
||||
Assert.False(vm.Slots[0].IsSignIn);
|
||||
Assert.Equal("My Channel", vm.Slots[0].Handle);
|
||||
Assert.False(vm.ShowSignInBanner);
|
||||
|
||||
// Fill row 1 again and save: YouTube-first ordering, bar enabled.
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "twitch.tv/streamer";
|
||||
vm.ConfirmEdit(1);
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
Assert.Equal(2, vm.CommittedEntries!.Count);
|
||||
Assert.Equal(SocialService.YouTube, vm.CommittedEntries[0].Service);
|
||||
Assert.Equal("My Channel", vm.CommittedEntries[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
|
||||
Assert.Equal("streamer", vm.CommittedEntries[1].Handle);
|
||||
|
||||
// Sign out via the row-0 delete: action called once, row 0 back to the gate.
|
||||
await vm.DeleteSlotAsync(0);
|
||||
Assert.Equal(1, _signOutCalls);
|
||||
Assert.False(vm.IsSignedIn);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
|
||||
// Full persistence roundtrip through the store.
|
||||
var config = vm.BuildSocialsConfig();
|
||||
Assert.NotNull(config);
|
||||
Assert.True(config!.BarEnabled);
|
||||
Assert.Equal(2, config.Entries.Count);
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-flow-{System.Guid.NewGuid():N}.db");
|
||||
SqliteConnection.ClearAllPools();
|
||||
try
|
||||
{
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { new Scene { Name = "Live" } }, null, config);
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.Load();
|
||||
Assert.NotNull(store.Socials);
|
||||
Assert.Equal(2, store.Socials!.Entries.Count);
|
||||
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||
Assert.True(store.Socials.BarEnabled);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests ──
|
||||
|
||||
[Fact]
|
||||
public void Freemium_LocksSlotsBeyondTwo_PremiumUnlocksAll()
|
||||
{
|
||||
var free = Create();
|
||||
Assert.True(free.Slots[2].IsLocked);
|
||||
Assert.True(free.Slots[5].IsLocked);
|
||||
Assert.False(free.Slots[1].IsLocked);
|
||||
|
||||
var premium = Create(isPremium: true);
|
||||
Assert.All(premium.Slots, s => Assert.False(s.IsLocked));
|
||||
Assert.False(premium.Slots[5].IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationFailure_LeavesSlotEditableAndBlocksSave()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/doesnotexist";
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.CanSave);
|
||||
|
||||
vm.Slots[1].EditText = "github.com/dev";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(SocialService.GitHub, vm.Slots[1].Service);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.True(vm.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyEdit_CancelsEditing()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/user";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedsFromCurrent_YouTubeBecomesAccountRow()
|
||||
{
|
||||
var current = new SocialsConfig { BarEnabled = false };
|
||||
current.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.YouTube,
|
||||
Handle = "stale-yt-handle",
|
||||
ProfileUrl = "https://www.youtube.com/@stale-yt-handle",
|
||||
});
|
||||
current.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Twitch,
|
||||
Handle = "oldstreamer",
|
||||
ProfileUrl = "https://www.twitch.tv/oldstreamer",
|
||||
});
|
||||
|
||||
var vm = Create(account: new YouTubeChannel { ChannelId = "UC123", DisplayName = "Fresh Channel" }, current: current);
|
||||
|
||||
Assert.False(vm.BarEnabled);
|
||||
Assert.Equal("Fresh Channel", vm.Slots[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.Slots[1].Service);
|
||||
Assert.Equal("oldstreamer", vm.Slots[1].Handle);
|
||||
Assert.True(vm.Slots[2].IsLocked);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.Equal(2, vm.CommittedEntries!.Count);
|
||||
Assert.Equal("Fresh Channel", vm.CommittedEntries[0].Handle);
|
||||
Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service);
|
||||
Assert.False(vm.BuildSocialsConfig()!.BarEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySave_BuildsNullConfig()
|
||||
{
|
||||
var vm = Create();
|
||||
Assert.True(vm.CanSave);
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
Assert.Empty(vm.CommittedEntries!);
|
||||
Assert.Null(vm.BuildSocialsConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignOut_ResetsRowZeroToGate()
|
||||
{
|
||||
var signIn = new SignInFake();
|
||||
var vm = Create(signIn: signIn, account: new YouTubeChannel { ChannelId = "UC1", DisplayName = "Ch" });
|
||||
Assert.True(vm.IsSignedIn);
|
||||
|
||||
await vm.DeleteSlotAsync(0);
|
||||
|
||||
Assert.Equal(1, _signOutCalls);
|
||||
Assert.False(vm.IsSignedIn);
|
||||
Assert.True(vm.Slots[0].IsSignIn);
|
||||
Assert.True(vm.ShowSignInBanner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FediverseHandle_ValidatesAndKeepsFullHandle()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "@creator@instance.tube";
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.Equal(SocialService.Fediverse, vm.Slots[1].Service);
|
||||
Assert.Equal("@creator@instance.tube", vm.Slots[1].Handle);
|
||||
Assert.Equal("https://instance.tube/@creator", vm.Slots[1].ProfileUrl);
|
||||
Assert.Equal("peertube", vm.Slots[1].FediverseSoftware);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), vm.Slots[1].LogoData);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FediverseEntry_CommitsSoftwareName()
|
||||
{
|
||||
var vm = Create();
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "@creator@instance.tube";
|
||||
vm.ConfirmEdit(1);
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.CommittedEntries);
|
||||
var entry = Assert.Single(vm.CommittedEntries!);
|
||||
Assert.Equal(SocialService.Fediverse, entry.Service);
|
||||
Assert.Equal("peertube", entry.FediverseSoftware);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
|
||||
var config = vm.BuildSocialsConfig();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), Assert.Single(config.Entries).LogoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_AbortsInFlightValidation_WithoutMutatingSlot()
|
||||
{
|
||||
var validator = new BlockingValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.True(vm.Slots[1].IsValidating);
|
||||
|
||||
vm.Cancel();
|
||||
// The lookup resolves with success AFTER dismissal — it must not apply.
|
||||
validator.Gate.TrySetResult(new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Handle = "creator",
|
||||
ProfileUrl = "https://x.com/creator",
|
||||
});
|
||||
|
||||
Assert.False(vm.Slots[1].IsFilled);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.False(vm.Slots[1].IsValidating);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReSubmittingIdenticalFailedInput_DoesNotRelookup()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/doesnotexist";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
|
||||
// LostFocus firing on Cancel re-submits the same failed text — no second lookup.
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].HasError);
|
||||
|
||||
// Editing the text re-enables validation.
|
||||
vm.Slots[1].EditText = "github.com/dev";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(2, validator.LookupCount);
|
||||
Assert.False(vm.Slots[1].HasError);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReEditingUnchangedHandle_ClosesBox_NoRelookup()
|
||||
{
|
||||
var validator = new FakeValidator();
|
||||
var vm = Create(validator);
|
||||
|
||||
vm.StartEdit(1);
|
||||
vm.Slots[1].EditText = "x.com/creator";
|
||||
vm.ConfirmEdit(1);
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
|
||||
vm.StartEdit(1);
|
||||
Assert.True(vm.Slots[1].IsEditing);
|
||||
vm.ConfirmEdit(1);
|
||||
|
||||
Assert.Equal(1, validator.LookupCount);
|
||||
Assert.False(vm.Slots[1].IsEditing);
|
||||
Assert.True(vm.Slots[1].IsFilled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Duplicate source names get an incrementing suffix with no space (Image,
|
||||
/// Image2, Image3…) and the next free number is derived from the names actually
|
||||
/// in the scene — deleting a middle resource never collides with a survivor.
|
||||
/// Drives the real VM through the Add Source command (TextOverlay needs no
|
||||
/// dialog), the same real-App + temp-DB pattern as the round-clip test.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class SourceNamingTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public SourceNamingTests(RealAppHost app)
|
||||
{
|
||||
_app = app;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_Sources_Get_Next_Free_Numbered_Name()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-srcname-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
var scene = vm.ActiveScene!;
|
||||
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
|
||||
|
||||
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
|
||||
scene.Elements.Remove(middle);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ public class YouTubeAuthServiceTests
|
||||
_channelResponse = channelResponse;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
|
||||
var body = isToken ? _tokenResponse : _channelResponse;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<Resource Include="Assets\llama-logo-icon.png"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ytLive.Tests</_Parameter1>
|
||||
@@ -32,6 +36,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
||||
<PackageReference Include="NAudio.Wasapi" Version="2.2.1"/>
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user