Compare commits

...

21 Commits

Author SHA1 Message Date
gramps d90b5ded0d TASK 7 UI polish batch: scene/source row cleanup, dedup naming, full social handles — scenes are now pure selection rows (edit/trash/visibility icons and inline rename removed with EditSceneCommand/RemoveSceneCommand/ToggleSceneVisibilityCommand + Scene.IsEditing); source rows gained the trio (new EditElementCommand → inline rename via SceneElement.IsEditing, ToggleElementVisibilityCommand → eye flips IsVisible, open/slashed style rebound, hidden rows dim to 45%); duplicate resource names get a no-space incrementing suffix via shared NextSourceName (Image, Image2, Image3…) derived from actual names so deletions never collide (AddSource + AddReusedImage); social bar renders the full validated handle (MaxWidth=200 + TextTrimming removed from SocialBarRenderer and the preview template); side panels stay fixed 220/300; focus-loss capture lag documented in ai.md as a known OS limit (deferred) — the two real-App tests now share RealAppHost, a dedicated STA thread owning the single WPF App, instead of each calling new App(); new SourceNamingTests integration test (Text/Text2/Text3, delete-middle re-add no-collision) — 170 tests passing, 0 warnings 2026-08-13 18:50:56 -07:00
gramps 8292663791 TASK 4 ship step 6: health stats land in the bottom bar — FramePump.HealthUpdated is now wired to the VM (OnFramePumpHealthUpdated marshals onto the UI thread — the encoder's stderr loop raises on a background thread — and copies the parsed bitrate/FPS/dropped/duration into CurrentHealth, the bottom bar's existing binding); ResetHealth zeroes dropped frames + elapsed duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets); the bar shows real encoder values once TASK 5 fills _rtmpUrlProvider (until then the pump skips the encoder) — 169 tests passing, 0 warnings 2026-08-13 15:56:29 -07:00
gramps dc29adf9bb TASK 4 audio round 3: mic mute icon + top-center REC sign + voice-filter to-do — the mic bar gained a second 16px clickable glyph (microphone, red + slash when muted) between the meter and the speaker so both mute toggles sit adjacent with spacing, reusing the same ToggleMicMuteCommand handler (no VM/code-behind change); the top-center LIVE badge became an always-visible REC sign driven by new VM props (RecDotBrush/RecTextBrush/RecDotOpacity/IsLivePrivate): dark gray dot + dim REC offline, bright red + REC + elapsed while live, darker red when live with a private stream (driven by the dialog's chosen StreamVisibility; the stream service still forces public — ship step 7), pulsing while live; LiveIndicatorVisible/LivePulseOpacity removed; task 22 queued (bass boost, treble, noise suppression, compressor on the mic channel, KISS, always on); HANDOFF documents the mic status contract (red until a mic resource actually connects) — 169 tests passing, 0 warnings 2026-08-13 12:13:00 -07:00
gramps 18abe4210f TASK 4 audio fixes round 2: dB-scaled meters, game bar moved into the preview, Notepad About button replaced by the logo → in-app About — AudioLevelMeter.ToDisplay maps -60..0 dBFS onto 0..1 (real speech/game RMS ~0.01..0.1 linear left the flat scale dead; both meters now scale through it before volume); the game audio bar moved OUT of the footer (back to two rows) and is now a bottom-center dark chip overlaid on the preview window — monitoring UI only, never on the live output; the top-bar About button (opened THIRD-PARTY-NOTICES.txt in the OS viewer) removed, the ytLlive wordmark now opens the in-app About overlay, and task 21 queued (real logo + creator-hub About with premium/coffee/socials links + in-app scrolling licensing panel) — 169 tests passing, 0 warnings 2026-08-13 11:56:22 -07:00
gramps 9f9ed34627 TASK 4 audio follow-up: game audio bar + mic status dot + always-on capture — the footer's second audio control (desktop/game, a mirror of the mic bar: meter + mute + volume) appears only while a full-screen game is producing sound (IGameAudioDetector seam + GameAudioHysteresis: show ~500ms of fullscreen+sound, hide ~1s after leaving fullscreen, silence never hides an active bar; VM polls on a 250ms timer); capture now runs for the app's lifetime so both meters preview live (started at startup via StartMicCaptureAsync, disposed in Shutdown — no longer go-live driven); MIC label is a button with a status dot (Models/MicStatus: green via the source Started event, yellow = mic problem, red = no device); PickMicrophone swaps the live device immediately via AudioMixer.RestartMic; fixed a latent ?.Invoke(meter.Push(...)) short-circuit that skipped the meter update when nothing subscribed — 167 tests passing, 0 warnings 2026-08-13 11:29:44 -07:00
gramps ea250c02a6 Social bar position is a click-toggle, not a drag — the drag-snap misbehaved for shaky hands (snapped up but wouldn't come back down; jitter around the deadzone). Clicking the bar now flips it top/bottom via ToggleSocialBarPosition; SocialBarSnap and its units removed — 153 tests passing, 0 warnings 2026-08-13 11:04:32 -07:00
gramps ac60a26d82 TASK 4 ship step 5.5: social bar bug fixes + bar on the live output — direction-snap drag (SocialBarSnap.Decide + ClearValue, local Canvas.Top was overriding the binding), fediverse nodeinfo subdomain probing + load-time heal (settable FediverseSoftware), SocialBarRenderer strip blitted above the flash via a re-read FramePump socialBar seam — 155 tests passing, 0 warnings 2026-08-13 09:29:58 -07:00
gramps e72ba71165 TASK 4 ship step 5: live frame pipeline — FramePump paces the active scene's composite into the encoder, ScreenCaptureManager.GetLatestFrame, MainViewModel resolver/option-builder/pump wiring, FramePumpTests (7) + GetLatestFrame test — 147 tests passing, 0 warnings 2026-08-13 08:57:56 -07:00
gramps 58c0f8e8c4 TASK 4 ship step 4: WASAPI audio capture — NAudio loopback + mic behind an IAudioSource seam, AudioMixer driving AudioLevel while live, pure level meter + WaveToFloat, NAudio.Wasapi 2.2.1 (MIT, notices item 9) — 139 tests passing, 0 warnings 2026-08-12 21:18:07 -07:00
gramps ba427e85e1 TASK 4 ship step 3: encoder + RTMP push — FFmpeg subprocess with probed H.264 picker, BGRA stdin feed, stderr health parsing, graceful stop — 122 tests passing, 0 warnings 2026-08-12 20:32:24 -07:00
gramps e494dce311 HANDOFF: next step is TASK 4 ship step 3 — encoder + RTMP push 2026-08-12 18:43:36 -07:00
gramps b094755e08 Rule: memory update ships in the SAME commit as the feature — follow-up docs backfill commits are a broken rule 2026-08-12 18:40:18 -07:00
gramps 7c456042a6 Docs backfill for social bar v2 (9873eed): schema v6->v8 in TASKS.md/ai.md, social bar architecture section + Simple Icons CC0 notice, README structure rows, HANDOFF rewritten (committed+pushed) 2026-08-12 18:39:08 -07:00
gramps 9873eeda7d Social bar v2: fediverse icons resolved via nodeinfo (with redirect-following), persisted software column, icon colors — 112 tests passing, 0 warnings 2026-08-12 18:28:59 -07:00
gramps db5fc06174 HANDOFF: social bar UX is wrong — user's correction lost to ctrl+enter, waiting for re-statement 2026-08-12 07:45:22 -07:00
gramps 8868f98392 Social bar UX: YT auto-add from connected account, two-box dialog (YT handle + URL/handle for other services), service auto-detection from URL domain, fediverse @user@domain support, footer repositioned (Social controls left under scenes/sources, meter centered under preview), validation with add-anyway fallback 2026-08-12 07:40:42 -07:00
gramps 5437c776fe Merge social-bar: global social bar resource with per-scene presence, validated lookups, schema v7 2026-08-12 07:33:08 -07:00
gramps 5e8b8e5835 Social bar (Branch B): global resource with per-scene presence — Models/Socials.cs (19 services, icons, canonical URLs), Services/SocialValidator.cs (HTTP lookup validation seam), LayoutStore schema v7 (Socials + SocialEntry tables + Scene.HasSocialBar), MainViewModel (socials collection, AddSocial/RemoveSocial/ToggleSceneSocialBar commands, freemium cap YT+1, premium seam), MainWindow.xaml (footer Social button + [+/-] toggle on meter line, preview bar rendering top/bottom + LCR), 4 integration tests (roundtrip, empty-not-persisted, canonical URLs, icons), 85 tests passing, 0 warnings 2026-08-12 07:33:03 -07:00
gramps e1d9e1388d Merge webcam-validation: resource validation + first-frame proof + CameraConflictProbe 2026-08-12 07:21:20 -07:00
gramps 3f7f1880c5 Webcam resource validation + first-frame proof + CameraConflictProbe: MediaCaptureFrameSource validates post-init (VideoDeviceId match, stream properties, reader StartAsync status), subscribes Failed/CameraStreamStateChanged → SourceFailed; CameraManager.AcquireAsync requires first-frame proof (4s timeout) — silent empty box impossible; MainViewModel surfaces WebcamError chip + MessageBox with suspect-app names; fallback ladder VideoPreview → VideoRecord; 19041 SDK projection gaps documented; AGENTS.md + schema.md + HANDOFF.md memory-map rules added; 81 tests passing, 0 warnings 2026-08-12 07:21:15 -07:00
gramps 60259c08c2 Fix CameraStreamState.Failed compile error (19041 projection omits enum member names — compare by int); fix first-frame proof test (no dispatcher = no bitmap, assert LatestFrame instead) 2026-08-12 07:11:54 -07:00
66 changed files with 7011 additions and 342 deletions
+16 -3
View File
@@ -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). 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. 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. 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 ## Working rules
- **Never work without the map.** If the map contradicts the code, the code wins - **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). 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 - **Every feature change ships with its memory update in the SAME commit:**
architecture/patterns, `TASKS.md` for status, index files when layout changes. `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, - **Follow existing conventions** — MVVM, `RelayCommand` for actions,
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml` `ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
(merged once in `App.xaml`; never duplicate per-window). (merged once in `App.xaml`; never duplicate per-window).
+85
View File
@@ -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`.
+47
View File
@@ -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>();
}
}
}
+1
View File
@@ -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 | | `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) | | `ImageCache.cs` | Image byte caching (assets live in the DB) |
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings | | `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 Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
class backs; [`Models/index.md`](../Models/index.md) and class backs; [`Models/index.md`](../Models/index.md) and
+228 -88
View File
@@ -49,21 +49,21 @@
</DrawingImage.Drawing> </DrawingImage.Drawing>
</DrawingImage> </DrawingImage>
<!-- Eye icon: open when visible, slashed when hidden --> <!-- Eye icon: open when visible, slashed when hidden (source rows) -->
<Style x:Key="EyeIconStyle" TargetType="Path"> <Style x:Key="EyeIconStyle" TargetType="Path">
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/> <Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True"> <DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/> <Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}"> <Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="ToolTip" Value="Hide Scene"/> <Setter Property="ToolTip" Value="Hide Source"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True"> <DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="ToolTip" Value="Show Scene"/> <Setter Property="ToolTip" Value="Show Source"/>
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -86,27 +86,32 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Logo / Brand --> <!-- Logo / Brand — clicking opens the in-app About overlay -->
<TextBlock Grid.Column="0" Text="ytLlive" <Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
Foreground="#e94560" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
VerticalAlignment="Center" Margin="0,0,24,0"/> Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
</Button>
<!-- LIVE badge (center) --> <!-- REC sign (center): dark offline, red live, darker red when private -->
<StackPanel Grid.Column="1" Orientation="Horizontal" <StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center">
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}"> <Ellipse Width="12" Height="12" VerticalAlignment="Center"
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center" Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
Opacity="{Binding LivePulseOpacity}"/> <TextBlock Text="REC" FontSize="16" FontWeight="Bold"
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold" Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14" <TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"/> FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel> </StackPanel>
<!-- Single three-state action button --> <!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="About" Style="{StaticResource YtButtonSecondary}"
Click="AboutButton_Click" Margin="0,0,12,0" VerticalAlignment="Center"/>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True" <Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}" ToolTip="{Binding AccountDisplayName}"
@@ -188,64 +193,8 @@
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<Grid Margin="0,2"> <Grid Margin="0,2">
<Grid.ColumnDefinitions> <TextBlock Text="{Binding Name}" VerticalAlignment="Center"
<ColumnDefinition Width="*"/> Padding="4,2" TextTrimming="CharacterEllipsis"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SceneNameBox_IsVisibleChanged"
KeyDown="SceneNameBox_KeyDown" LostFocus="SceneNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Scene Name"
Command="{Binding DataContext.EditSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleSceneVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Delete Scene"
Command="{Binding DataContext.RemoveSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@@ -282,6 +231,11 @@
<Helpers:FocusPreservingListBox.ItemContainerStyle> <Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}"> <Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style> </Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle> </Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
@@ -290,6 +244,8 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.ContextMenu> <Grid.ContextMenu>
@@ -305,9 +261,51 @@
</Grid.ContextMenu> </Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" <TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/> Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Button Grid.Column="1" ToolTip="Remove Source" <TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Source Name"
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Remove Source"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}" CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}"> Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
@@ -651,6 +649,40 @@
FontSize="180" TextAlignment="Center"/> FontSize="180" TextAlignment="Center"/>
</Viewbox> </Viewbox>
</Grid> </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> </Canvas>
</Grid> </Grid>
</Viewbox> </Viewbox>
@@ -658,6 +690,12 @@
Background="#CC16213e" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"> Background="#CC16213e" CornerRadius="4" Padding="8,3" IsHitTestVisible="False">
<TextBlock Text="{Binding ResolutionBadgeText}" Foreground="#e0e0e0" FontSize="12"/> <TextBlock Text="{Binding ResolutionBadgeText}" Foreground="#e0e0e0" FontSize="12"/>
</Border> </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" <TextBlock Text="Preview" Foreground="#333" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
@@ -702,6 +740,63 @@
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/> Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Game audio bar: overlaid at the bottom of the preview
window while a full-screen game is producing sound
(the game audio detector). Monitoring UI only — it
lives in the XAML preview and never reaches the live
output. Desktop audio itself is automatic WASAPI
loopback. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12"
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Game Audio Capture" Foreground="#a0a0b0" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Desktop/game audio via WASAPI loopback (automatic)"/>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
</StackPanel>
</Border>
</Grid> </Grid>
</Grid> </Grid>
</Border> </Border>
@@ -755,16 +850,47 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Line 1: sound meter + volume control, centered beneath the <!-- Line 1: Social controls on the LEFT (under scenes/sources),
middle (preview) panel. Audio is KISS: desktop/game audio is sound meter + volume control CENTERED beneath the preview panel.
automatic ("it just is" — WASAPI loopback at unity, zero UI); Audio is KISS: desktop/game audio is automatic (WASAPI loopback
the creator's only audio control is the mic — meter, volume, mute. --> at unity); the mic meter is the creator's only footer audio
<StackPanel Grid.Row="0" Orientation="Horizontal" 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"> HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" <Button Command="{Binding OpenSocialDialogCommand}"
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand" Style="{StaticResource YtButtonSecondary}" Padding="12,4"
ToolTip="Choose the microphone (voice source)" FontSize="11" VerticalAlignment="Center"
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/> 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 <!-- Sound meter: muted track with a ruler scale, zone tints at
the yellow (60%) and red (80%) starts. READ-ONLY realtime the yellow (60%) and red (80%) starts. READ-ONLY realtime
level display (fill = live level scaled by volume). --> level display (fill = live level scaled by volume). -->
@@ -796,6 +922,19 @@
VerticalAlignment="Stretch"/> VerticalAlignment="Stretch"/>
</Grid> </Grid>
</Border> </Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0" <Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}" VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp"> MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
@@ -815,6 +954,7 @@
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp" PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="VolumeSlider_LostMouseCapture"/> LostMouseCapture="VolumeSlider_LostMouseCapture"/>
</StackPanel> </StackPanel>
</Grid>
<!-- Line 2: everything else — stream stats on the left, quality + <!-- Line 2: everything else — stream stats on the left, quality +
gear on the right. --> gear on the right. -->
+43 -31
View File
@@ -1,7 +1,5 @@
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -65,7 +63,7 @@ public partial class MainWindow : Window
: null; : null;
} }
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{ {
if (sender is TextBox { IsVisible: true } box) if (sender is TextBox { IsVisible: true } box)
{ {
@@ -83,12 +81,6 @@ public partial class MainWindow : Window
} }
} }
private void MicLabel_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.OpenMicPickerCommand.Execute(null);
}
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
if (sender is FrameworkElement { DataContext: MainViewModel vm }) if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -107,6 +99,24 @@ public partial class MainWindow : Window
vm.SetVolumeAdjusting(false); vm.SetVolumeAdjusting(false);
} }
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(true);
}
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{ {
if (sender is FrameworkElement { DataContext: MainViewModel vm }) if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -115,6 +125,14 @@ public partial class MainWindow : Window
} }
} }
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleGameMuteCommand.Execute(null);
}
}
private void GearButton_Click(object sender, RoutedEventArgs e) private void GearButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) if (sender is Button { ContextMenu: { } menu } button)
@@ -125,21 +143,6 @@ public partial class MainWindow : Window
} }
} }
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
// LGPL/BSD/MIT notices ship next to the exe; open in the OS text viewer.
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
if (!File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
AppLog.Write(ex, "About: failed to open THIRD-PARTY-NOTICES.txt");
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e) private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) if (sender is Button { ContextMenu: { } menu } button)
@@ -254,6 +257,15 @@ public partial class MainWindow : Window
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
// ─── Social bar: 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) private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
if (OpacityChip.IsMouseOver) return; if (OpacityChip.IsMouseOver) return;
@@ -360,24 +372,24 @@ public partial class MainWindow : Window
return null; return null;
} }
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e) private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{ {
if (e.Key is Key.Enter or Key.Escape) if (e.Key is Key.Enter or Key.Escape)
{ {
CommitSceneEdit(sender); CommitSourceEdit(sender);
e.Handled = true; e.Handled = true;
} }
} }
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e) private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{ {
CommitSceneEdit(sender); CommitSourceEdit(sender);
} }
private static void CommitSceneEdit(object sender) private static void CommitSourceEdit(object sender)
{ {
if (sender is FrameworkElement { DataContext: Scene scene }) if (sender is FrameworkElement { DataContext: SceneElement element })
scene.IsEditing = false; element.IsEditing = false;
} }
// ─── List drag-to-reorder (scenes list + sources list) ─── // ─── List drag-to-reorder (scenes list + sources list) ───
+13
View File
@@ -0,0 +1,13 @@
namespace ytLive.Models;
/// <summary>Footer mic indicator state (the dot beside the MIC button):
/// <see cref="Connected"/> = mic capture is running; <see cref="Problem"/> = a
/// mic was requested but the connection failed (device in use, unplugged, or
/// unavailable); <see cref="NotConnected"/> = no capture is active (e.g. no
/// mic device present).</summary>
public enum MicStatus
{
NotConnected,
Connected,
Problem
}
+11 -7
View File
@@ -9,9 +9,9 @@ public class Scene : INotifyPropertyChanged
public string Id { get; init; } = Guid.NewGuid().ToString(); public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty; private string _name = string.Empty;
private bool _isEditing;
private bool _isHidden; private bool _isHidden;
private bool _hasBackdrop; private bool _hasBackdrop;
private bool _hasSocialBar;
public string Name public string Name
{ {
@@ -19,12 +19,6 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _name, value); set => Set(ref _name, value);
} }
public bool IsEditing
{
get => _isEditing;
set => Set(ref _isEditing, value);
}
public bool IsHidden public bool IsHidden
{ {
get => _isHidden; get => _isHidden;
@@ -41,6 +35,16 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _hasBackdrop, value); 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> /// <summary>
/// The scene's rendered elements in z-order (back to front): multi-instance /// 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. /// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
+3
View File
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
private bool _isVisible = true; private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); } public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
private bool _isEditing;
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional; private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape public ClipShape ClipShape
{ {
+265
View File
@@ -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;
}
}
+2
View File
@@ -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` | | `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math | | `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum | | `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) | | `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain`**Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these; Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`. [`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
+4 -3
View File
@@ -45,12 +45,13 @@ dotnet run
| Path | Role | | Path | Role |
|------|------| |------|------|
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat | | `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel | | `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite) | | `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 | | `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` | | `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 | | `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) ## Docs (memory map)
+49
View File
@@ -0,0 +1,49 @@
namespace ytLive.Services.Audio;
/// <summary>
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
/// does not flicker.
/// </summary>
public sealed class AudioLevelMeter
{
private const float Smoothing = 0.2f;
private float _level;
public float Level => _level;
/// <summary>
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
/// spread linearly across 0..1. Linear RMS of real speech or game audio is
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking
/// dead; the log scale makes typical levels occupy the bar.
/// </summary>
public static float ToDisplay(float linear)
{
if (linear <= 0.001f)
return 0f;
var db = 20f * MathF.Log10(linear);
return Math.Clamp(1f + db / 60f, 0f, 1f);
}
public float Push(AudioSample sample)
{
if (sample.Samples.Length == 0)
return _level;
double sumSquares = 0;
var count = 0;
foreach (var value in sample.Samples)
{
sumSquares += value * value;
count++;
}
var rms = (float)Math.Sqrt(sumSquares / count);
_level = _level * (1 - Smoothing) + rms * Smoothing;
return _level;
}
public void Reset() => _level = 0;
}
+133
View File
@@ -0,0 +1,133 @@
using ytLive.Services;
namespace ytLive.Services.Audio;
/// <summary>
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
/// the footer meters. Capture runs for the app's lifetime (started once at
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
/// The mixer surfaces mic connection state (Connected/Failed) for the status
/// dot and can restart the mic source mid-session when a device is re-picked.
/// </summary>
public sealed class AudioMixer : IDisposable
{
private readonly IAudioSource _mic;
private readonly IAudioSource _loopback;
private readonly AudioLevelMeter _meter;
private readonly AudioLevelMeter _loopbackMeter;
private readonly Action<string>? _log;
private bool _started;
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
{
_mic = mic;
_loopback = loopback;
_meter = new AudioLevelMeter();
_loopbackMeter = new AudioLevelMeter();
_log = log;
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
_loopback.SampleReady += OnLoopbackSample;
_mic.Failed += OnMicFailed;
_loopback.Failed += OnLoopbackFailed;
}
/// <summary>Current smoothed mic level (0..1).</summary>
public float MicLevel => _meter.Level;
/// <summary>Current smoothed desktop/game level (0..1).</summary>
public float LoopbackLevel => _loopbackMeter.Level;
/// <summary>Raised whenever the smoothed mic level changes.</summary>
public event Action<float>? MicLevelChanged;
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
public event Action<float>? LoopbackLevelChanged;
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
public event Action? MicConnected;
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
public event Action<Exception>? MicFailed;
public void Start()
{
if (_started)
return;
_started = true;
_meter.Reset();
_loopback.Start();
_mic.Start();
}
/// <summary>Swaps the mic source without touching loopback — used when the
/// creator picks a different device mid-session. The level resets and the
/// new source raises <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
public void RestartMic()
{
_mic.Stop();
_meter.Reset();
MicLevelChanged?.Invoke(0);
_mic.Start();
}
public void Stop()
{
if (!_started)
return;
_started = false;
_mic.Stop();
_loopback.Stop();
_meter.Reset();
_loopbackMeter.Reset();
MicLevelChanged?.Invoke(0);
LoopbackLevelChanged?.Invoke(0);
}
public void Dispose()
{
Stop();
_mic.Started -= OnMicStarted;
_mic.SampleReady -= OnMicSample;
_loopback.SampleReady -= OnLoopbackSample;
_mic.Failed -= OnMicFailed;
_loopback.Failed -= OnLoopbackFailed;
_mic.Dispose();
_loopback.Dispose();
}
private void OnMicStarted()
{
MicConnected?.Invoke();
}
private void OnMicSample(AudioSample sample)
{
// Push unconditionally: the ?. on the event would otherwise skip the
// argument (and the meter update) when nothing is subscribed yet.
var level = _meter.Push(sample);
MicLevelChanged?.Invoke(level);
}
private void OnLoopbackSample(AudioSample sample)
{
var level = _loopbackMeter.Push(sample);
LoopbackLevelChanged?.Invoke(level);
}
private void OnMicFailed(Exception ex)
{
_log?.Invoke($"Mic capture failed: {ex.Message}");
MicLevelChanged?.Invoke(0);
MicFailed?.Invoke(ex);
}
private void OnLoopbackFailed(Exception ex)
{
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace ytLive.Services.Audio;
/// <summary>
/// A chunk of interleaved PCM float samples (-1..1) with its format. This is
/// the unit every <see cref="IAudioSource"/> produces and the
/// <see cref="AudioMixer"/> consumes (TASK 4 ship step 4).
/// </summary>
public sealed class AudioSample
{
public float[] Samples { get; }
public int SampleRate { get; }
public int Channels { get; }
public AudioSample(float[] samples, int sampleRate, int channels)
{
Samples = samples;
SampleRate = sampleRate;
Channels = channels;
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Services.Audio;
/// <summary>
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
/// chunks. The default implementations wrap NAudio's WASAPI capture (mic) /
/// loopback (desktop/game); the mixer and the tests consume this interface,
/// never NAudio directly. Capture now runs for the app's lifetime so the
/// footer meters stay live in preview — the mixer owns start/stop.
/// </summary>
public interface IAudioSource : IDisposable
{
/// <summary>Starts capturing. Safe to call only once per Stop.</summary>
void Start();
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
void Stop();
/// <summary>Raises once capture is live (right after recording starts).
/// Never raised when Start fails — <see cref="Failed"/> fires instead.</summary>
event Action? Started;
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
event Action<AudioSample>? SampleReady;
/// <summary>Raises when capture dies or fails to start (e.g. no device).</summary>
event Action<Exception>? Failed;
}
@@ -0,0 +1,72 @@
using NAudio.Wave;
namespace ytLive.Services.Audio;
/// <summary>
/// Captures the user's desktop/game audio (TASK 4 ship step 4) via WASAPI
/// loopback on the default render device. Runs for the app's lifetime so the
/// game audio bar stays live in preview.
/// </summary>
public sealed class WasapiLoopbackAudioSource : IAudioSource
{
private WasapiLoopbackCapture? _capture;
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
public void Start()
{
if (_capture != null)
throw new InvalidOperationException("Already started.");
try
{
_capture = new WasapiLoopbackCapture();
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{
Stop();
Failed?.Invoke(ex);
}
}
public void Stop()
{
if (_capture == null)
return;
try
{
_capture.StopRecording();
}
catch
{
}
_capture.Dispose();
_capture = null;
}
public void Dispose() => Stop();
private void OnDataAvailable(object? sender, WaveInEventArgs e)
{
if (e.BytesRecorded <= 0)
return;
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
if (samples.Length > 0)
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
}
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
{
if (e.Exception != null)
Failed?.Invoke(e.Exception);
}
}
+105
View File
@@ -0,0 +1,105 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
namespace ytLive.Services.Audio;
/// <summary>
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
/// persists DisplayName), falling back to the default capture endpoint.
/// </summary>
public sealed class WasapiMicAudioSource : IAudioSource
{
private readonly Func<string?> _micNameProvider;
private WasapiCapture? _capture;
/// <param name="micNameProvider">Returns the current mic DisplayName; read
/// at each Start so a device picked mid-session takes effect immediately
/// (the mixer restarts the mic on pick).</param>
public WasapiMicAudioSource(Func<string?> micNameProvider)
{
_micNameProvider = micNameProvider;
}
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
public void Start()
{
if (_capture != null)
throw new InvalidOperationException("Already started.");
try
{
var device = ResolveDevice();
_capture = device != null ? new WasapiCapture(device) : new WasapiCapture();
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{
Stop();
Failed?.Invoke(ex);
}
}
public void Stop()
{
if (_capture == null)
return;
try
{
_capture.StopRecording();
}
catch
{
}
_capture.Dispose();
_capture = null;
}
public void Dispose() => Stop();
private MMDevice? ResolveDevice()
{
var micName = _micNameProvider();
if (string.IsNullOrWhiteSpace(micName))
return null;
try
{
using var enumerator = new MMDeviceEnumerator();
foreach (var endpoint in enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active))
{
if (string.Equals(endpoint.FriendlyName, micName, StringComparison.OrdinalIgnoreCase))
return endpoint;
}
}
catch
{
}
return null;
}
private void OnDataAvailable(object? sender, WaveInEventArgs e)
{
if (e.BytesRecorded <= 0)
return;
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
if (samples.Length > 0)
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
}
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
{
if (e.Exception != null)
Failed?.Invoke(e.Exception);
}
}
+41
View File
@@ -0,0 +1,41 @@
using NAudio.Dmo;
using NAudio.Wave;
namespace ytLive.Services.Audio;
/// <summary>
/// Converts NAudio's raw WASAPI capture buffers (byte[], whatever bit depth the
/// device's mix format reports) into interleaved PCM float samples. Pure —
/// unit-tested; the two WASAPI sources share it.
/// </summary>
public static class WaveToFloat
{
public static float[] Convert(byte[] buffer, int bytesRecorded, WaveFormat format)
{
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
return ConvertIeeeFloat(buffer, bytesRecorded);
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
return ConvertPcm16(buffer, bytesRecorded);
if (format is WaveFormatExtensible extensible
&& extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT)
return ConvertIeeeFloat(buffer, bytesRecorded);
return ConvertPcm16(buffer, bytesRecorded);
}
private static float[] ConvertIeeeFloat(byte[] buffer, int bytesRecorded)
{
var count = bytesRecorded / 4;
var result = new float[count];
Buffer.BlockCopy(buffer, 0, result, 0, count * 4);
return result;
}
private static float[] ConvertPcm16(byte[] buffer, int bytesRecorded)
{
var count = bytesRecorded / 2;
var result = new float[count];
for (var i = 0; i < count; i++)
result[i] = BitConverter.ToInt16(buffer, i * 2) / 32768f;
return result;
}
}
+73 -8
View File
@@ -15,6 +15,12 @@ namespace ytLive.Services;
/// disposes the source. Frames arrive on a worker thread and are coalesced onto /// 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 /// the UI dispatcher (at most one pending copy per session, using the latest
/// frame) so a 60fps device doesn't drown the render thread. /// 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> /// </summary>
public sealed class CameraManager : IDisposable public sealed class CameraManager : IDisposable
{ {
@@ -23,11 +29,13 @@ public sealed class CameraManager : IDisposable
public string DeviceId { get; } public string DeviceId { get; }
public ICameraFrameSource Source { get; } public ICameraFrameSource Source { get; }
public Action<VideoFrame>? FrameHandler; public Action<VideoFrame>? FrameHandler;
public Action<string>? FailureHandler;
public int RefCount; public int RefCount;
public bool Started; public bool Started;
public WriteableBitmap? PreviewBitmap; public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame; public VideoFrame? LatestFrame;
public bool FramePending; public bool FramePending;
public TaskCompletionSource<bool>? FirstFrame;
public CameraSession(string deviceId, ICameraFrameSource source) public CameraSession(string deviceId, ICameraFrameSource source)
{ {
@@ -40,21 +48,26 @@ public sealed class CameraManager : IDisposable
private readonly ICameraEnumerator _enumerator; private readonly ICameraEnumerator _enumerator;
private readonly Func<string, ICameraFrameSource> _frameSourceFactory; private readonly Func<string, ICameraFrameSource> _frameSourceFactory;
private readonly Dispatcher? _uiDispatcher; private readonly Dispatcher? _uiDispatcher;
private readonly TimeSpan _firstFrameTimeout;
private readonly Dictionary<string, CameraSession> _sessions = new(); private readonly Dictionary<string, CameraSession> _sessions = new();
private readonly object _gate = new(); private readonly object _gate = new();
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary> /// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged; 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 event Action<string, string>? CameraFailed;
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory, public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
Dispatcher? uiDispatcher = null) Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
{ {
_enumerator = enumerator; _enumerator = enumerator;
_frameSourceFactory = frameSourceFactory; _frameSourceFactory = frameSourceFactory;
_uiDispatcher = uiDispatcher; _uiDispatcher = uiDispatcher;
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
} }
public ICameraEnumerator Enumerator => _enumerator; public ICameraEnumerator Enumerator => _enumerator;
@@ -79,6 +92,8 @@ public sealed class CameraManager : IDisposable
session = new CameraSession(deviceId, _frameSourceFactory(deviceId)); session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
session.FrameHandler = frame => OnFrameAvailable(session, frame); session.FrameHandler = frame => OnFrameAvailable(session, frame);
session.Source.FrameAvailable += session.FrameHandler; session.Source.FrameAvailable += session.FrameHandler;
session.FailureHandler = message => OnSourceFailed(session, message);
session.Source.SourceFailed += session.FailureHandler;
_sessions[deviceId] = session; _sessions[deviceId] = session;
shouldStart = true; shouldStart = true;
} }
@@ -86,20 +101,32 @@ public sealed class CameraManager : IDisposable
if (!shouldStart) return session.Started; if (!shouldStart) return session.Started;
session.FirstFrame = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
try try
{ {
await session.Source.StartAsync(); 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; session.Started = true;
return true; return true;
} }
catch (Exception ex) catch (Exception ex)
{ {
lock (_gate) RollbackSession(session, ex.Message);
_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);
return false; return false;
} }
} }
@@ -174,10 +201,47 @@ public sealed class CameraManager : IDisposable
foreach (var session in sessions) foreach (var session in sessions)
{ {
session.Source.FrameAvailable -= session.FrameHandler; session.Source.FrameAvailable -= session.FrameHandler;
if (session.FailureHandler != null)
session.Source.SourceFailed -= session.FailureHandler;
_ = SafeStopAsync(session.Source); _ = 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) private static async Task SafeStopAsync(ICameraFrameSource source)
{ {
try try
@@ -200,6 +264,7 @@ public sealed class CameraManager : IDisposable
{ {
if (!TryGetActiveSession(session)) return; if (!TryGetActiveSession(session)) return;
session.LatestFrame = frame; session.LatestFrame = frame;
session.FirstFrame?.TrySetResult(true);
if (session.PreviewBitmap == null) if (session.PreviewBitmap == null)
{ {
+18 -9
View File
@@ -18,13 +18,16 @@ public sealed class SceneCompositor
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back → /// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image → /// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) → /// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash. Transparent regions read opaque black. /// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
/// </summary> /// </summary>
public VideoFrame Render( public VideoFrame Render(
Scene scene, Scene scene,
Func<SceneElement, VideoFrame?> frameFor, Func<SceneElement, VideoFrame?> frameFor,
VideoFrame? flashFrame, VideoFrame? flashFrame,
CompositorOptions options) CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0)
{ {
if (scene == null) throw new ArgumentNullException(nameof(scene)); if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor)); if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -79,7 +82,10 @@ public sealed class SceneCompositor
} }
if (flashFrame != null) if (flashFrame != null)
BlitFlash(buffer, cropW, cropH, options, flashFrame); BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
return StretchMath.BilinearScale( return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight); new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
@@ -166,17 +172,20 @@ public sealed class SceneCompositor
} }
} }
/// <summary>1:1 copy of the master-sized branding flash, cropped to the active source rect.</summary> /// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash) /// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
private static void BlitOverlay(
byte[] dst, int dstW, int dstH, CompositorOptions options,
VideoFrame overlay, int sx0, int sy0)
{ {
for (var y = 0; y < dstH; y++) for (var y = 0; y < dstH; y++)
{ {
for (var x = 0; x < dstW; x++) for (var x = 0; x < dstW; x++)
{ {
var sx = x + options.SourceRectX; var sx = x + options.SourceRectX - sx0;
var sy = y + options.SourceRectY; var sy = y + options.SourceRectY - sy0;
if (sx >= flash.Width || sy >= flash.Height) continue; if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy); var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f); BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
} }
} }
+105
View File
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>
/// Rasterizes the global social bar into a transparent BGRA8 strip the output
/// compositor overlays onto the master frame (top or bottom edge). Replicates the
/// preview's bar DataTemplate in code — one Path (logo) + one TextBlock (handle)
/// per entry, white on transparent, green glow — then software-renders it via
/// <c>RenderTargetBitmap</c> and converts Pbgra32 (premultiplied) to straight-alpha
/// BGRA for the compositor. WPF glue like <see cref="StaticPixelCache"/>: the
/// compositor core itself stays pure byte-math. Renders on the UI thread only;
/// the returned frame is immutable afterwards, so the frame pump may read it from
/// any thread.
/// </summary>
public static class SocialBarRenderer
{
/// <summary>Icon size + the preview's 8px top/bottom margins.</summary>
private const int ContentHeight = 40;
/// <summary>Room for the green DropShadowEffect blur to bleed past the content.</summary>
private const int GlowPad = 24;
/// <summary>Master-frame width — the bar spans the full 1920px output.</summary>
public const int Width = 1920;
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries)
{
var list = entries?.Where(e => e != null).ToList();
if (list == null || list.Count == 0) return null;
var height = ContentHeight + GlowPad * 2;
var row = new StackPanel { Orientation = Orientation.Horizontal };
foreach (var entry in list)
{
var item = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 20, 0),
};
item.Children.Add(new Path
{
Data = Geometry.Parse(entry.LogoData),
Fill = Brushes.White,
Width = 24,
Height = 24,
Stretch = Stretch.Uniform,
VerticalAlignment = VerticalAlignment.Center,
});
item.Children.Add(new TextBlock
{
Text = entry.Handle,
Foreground = Brushes.White,
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
});
row.Children.Add(item);
}
// The glow is baked in so the output bar matches the preview's green halo.
var container = new Grid
{
Width = Width,
Height = height,
Background = Brushes.Transparent,
Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 },
};
var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) };
centered.Children.Add(row);
container.Children.Add(centered);
container.Measure(new Size(Width, height));
container.Arrange(new Rect(0, 0, Width, height));
var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(container);
var pixels = new byte[Width * height * 4];
bitmap.CopyPixels(pixels, Width * 4, 0);
// Pbgra32 is premultiplied — unpremultiply so the compositor's straight-alpha
// source-over blend doesn't darken the logo edges with a halo.
for (var i = 0; i < pixels.Length; i += 4)
{
var a = pixels[i + 3];
if (a == 0 || a == 255) continue;
pixels[i] = (byte)(pixels[i] * 255 / a);
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
}
return new VideoFrame(Width, height, pixels);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/&lt;key&gt;</c>).
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
/// placeholder track until the WASAPI capture step replaces the input.
/// </summary>
public sealed class EncoderOptions
{
public string RtmpUrl { get; init; } = string.Empty;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
public int Fps { get; init; } = 60;
public int BitrateKbps { get; init; } = 8000;
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
public string? VideoEncoder { get; init; }
public int AudioSampleRate { get; init; } = 48000;
public int AudioChannels { get; init; } = 2;
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
public int GopSize => Fps * 4;
}
+46
View File
@@ -0,0 +1,46 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
/// muxing to the ingestion URL. Pure — the encoder just starts
/// <c>ffmpeg.exe [Build(...)]</c>.
/// </summary>
public static class FfmpegArgs
{
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
{
var gop = options.GopSize;
return
[
"-hide_banner",
"-loglevel", "info",
"-stats",
"-stats_period", "0.5",
"-re",
"-f", "rawvideo",
"-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(),
"-i", "pipe:0",
"-f", "lavfi",
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
"-c:v", videoEncoder,
"-b:v", $"{options.BitrateKbps}k",
"-maxrate", $"{options.BitrateKbps}k",
"-bufsize", $"{options.BitrateKbps * 2}k",
"-g", gop.ToString(),
"-keyint_min", gop.ToString(),
"-sc_threshold", "0",
"-bf", "0",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "128k",
"-ar", options.AudioSampleRate.ToString(),
"-ac", options.AudioChannels.ToString(),
"-f", "flv",
options.RtmpUrl,
];
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.Diagnostics;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services.Encoder;
/// <summary>
/// The default <see cref="IFfmpegEncoder"/>: spawns <c>ffmpeg.exe</c> (resolved via
/// <see cref="IFfmpegLocator"/>), feeds raw BGRA frames into stdin, and parses the
/// <c>-stats</c> progress lines into <see cref="StreamHealth"/>. Encoder choice is
/// probed from the binary's <c>-encoders</c> listing (hardware NVENC/QSV/AMF first,
/// OpenH264 software fallback — never libx264, see the license posture) unless
/// <see cref="EncoderOptions.VideoEncoder"/> forces one.
///
/// Graceful stop = close stdin (EOF) → ffmpeg finalizes the FLV and exits by itself;
/// a watchdogs kill fires only if it hasn't exited shortly after EOF.
/// </summary>
public sealed class FfmpegEncoder : IFfmpegEncoder
{
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
private readonly IFfmpegLocator _locator;
private readonly Func<IEncoderProcess> _processFactory;
private readonly object _gate = new();
private IEncoderProcess? _process;
private EncoderOptions? _options;
private StreamHealth _health = new() { Status = StreamStatus.Offline };
private Task? _stderrLoop;
private bool _stopRequested;
public FfmpegEncoder(
IFfmpegLocator locator,
Func<IEncoderProcess>? processFactory = null)
{
_locator = locator;
_processFactory = processFactory ?? (() => new FfmpegEncoderProcess());
}
public bool IsRunning { get; private set; }
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
if (options == null) throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
lock (_gate)
{
if (IsRunning) throw new InvalidOperationException("The encoder is already running.");
_options = options;
}
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var arg in args) startInfo.ArgumentList.Add(arg);
IEncoderProcess process;
try
{
process = _processFactory();
process.Start(startInfo);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
lock (_gate) _options = null;
throw;
}
lock (_gate)
{
_process = process;
IsRunning = true;
_health = new StreamHealth { Status = StreamStatus.Streaming };
}
_stopRequested = false;
_stderrLoop = RunStderrLoopAsync(process);
}
/// <summary>
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
/// (the compositor pump) may race freely. Frames are written as-is — the caller
/// paces to capture rate (the compositor's job, ship step 5).
/// </summary>
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
if (frame == null) throw new ArgumentNullException(nameof(frame));
IEncoderProcess? process;
lock (_gate)
{
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
process = _process;
}
var bytes = frame.BgraPixels;
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
IEncoderProcess? process;
Task? loop;
lock (_gate)
{
if (!IsRunning) return;
_stopRequested = true;
process = _process;
loop = _stderrLoop;
}
try
{
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
}
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await process!.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
process!.Kill();
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
process!.Kill();
}
try
{
if (loop != null) await loop.ConfigureAwait(false);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
}
process.Dispose();
lock (_gate)
{
IsRunning = false;
_health.Status = StreamStatus.Offline;
_health.LastError = null;
_process = null;
_options = null;
}
AppLog.Write($"FFmpeg encoder stopped (exit {process.ExitCode})");
}
public void Dispose()
{
lock (_gate)
{
if (!IsRunning) return;
_process?.Kill();
_process?.Dispose();
_process = null;
IsRunning = false;
}
}
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-encoders");
using var probe = _processFactory();
probe.Start(startInfo);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var output = await probe.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
return FfmpegEncoderPicker.Pick(output);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
return FfmpegEncoderPicker.Preference[^1];
}
}
private Task RunStderrLoopAsync(IEncoderProcess process)
{
return Task.Run(async () =>
{
try
{
while (true)
{
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
if (line == null) break;
OnStderrLine(process, line);
}
var code = process.ExitCode;
var stillRunning = false;
lock (_gate) stillRunning = IsRunning;
if (stillRunning && !_stopRequested && code != 0)
{
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
_health.LastError = $"FFmpeg exited with code {code}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
}
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
}
});
}
private void OnStderrLine(IEncoderProcess process, string line)
{
var progress = FfmpegProgressParser.TryParse(line);
if (progress == null) return;
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
lock (_gate)
{
_health.CurrentBitrate = progress.Value.BitrateKbps;
_health.FPS = progress.Value.Fps;
_health.DroppedFrames = (int)dropped;
_health.StreamDuration = progress.Value.Duration;
}
HealthUpdated?.Invoke(this, _health);
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Picks the best available H.264 encoder from ffmpeg's <c>-encoders</c> listing,
/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then
/// the OpenH264 software fallback. Pure parser — the <c>-encoders</c> probe output is
/// fetched by the encoder via an <see cref="IEncoderProcess"/> and fed here.
/// </summary>
public static class FfmpegEncoderPicker
{
/// <summary>Preference order, best first. All ship in the pinned BtbN lgpl-shared build.</summary>
public static readonly string[] Preference =
[
"h264_nvenc",
"h264_qsv",
"h264_amf",
"libopenh264",
];
/// <summary>
/// First <see cref="Preference"/> entry present in the probe output, or the
/// software fallback (which the pinned build always contains) if none matched.
/// Never returns libx264 — it is GPL and would contaminate the paid product.
/// </summary>
public static string Pick(string probeOutput)
{
var available = probeOutput.Split('\n');
foreach (var name in Preference)
{
if (available.Any(line => line.Contains(name, StringComparison.Ordinal)))
return name;
}
return Preference[^1];
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services.Encoder;
/// <summary>
/// The real <see cref="IEncoderProcess"/>: a <see cref="Process"/> with all three
/// std streams redirected. Constructed by <see cref="FfmpegEncoder"/> for both the
/// encoder subprocess and the <c>-encoders</c> probe.
/// </summary>
public sealed class FfmpegEncoderProcess : IEncoderProcess
{
private readonly Process _process;
public FfmpegEncoderProcess() => _process = new Process { EnableRaisingEvents = true };
public void Start(ProcessStartInfo startInfo)
{
_process.StartInfo = startInfo;
_process.Start();
}
public Stream StandardInput => _process.StandardInput.BaseStream;
public TextReader StandardOutput => _process.StandardOutput;
public TextReader StandardError => _process.StandardError;
public bool HasExited => _process.HasExited;
public int ExitCode => _process.ExitCode;
public void Kill()
{
if (!_process.HasExited) _process.Kill();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
=> _process.WaitForExitAsync(cancellationToken);
public void Dispose() => _process.Dispose();
}
+47
View File
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
namespace ytLive.Services.Encoder;
/// <summary>A decoded ffmpeg <c>-stats</c> progress line (pure data).</summary>
public readonly record struct FfmpegProgress(
long Frame,
double Fps,
double BitrateKbps,
TimeSpan Duration,
long SizeBytes);
/// <summary>
/// Pure parser for ffmpeg's periodic <c>frame= fps= size= time= bitrate=</c> stderr
/// lines (the <c>-stats</c>/<c>-stats_period</c> output). Unit-tested in isolation
/// so the encoder loop stays a thin wire.
/// </summary>
public static class FfmpegProgressParser
{
// frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x
private static readonly Regex Line = new(
@"frame=\s*(?<frame>\d+)\s+fps=\s*(?<fps>[\d.]+).*?"
+ @"size=\s*(?<size>\d+)KiB.*?"
+ @"time=(?<time>\d{2}):(?<min>\d{2}):(?<sec>[\d.]+).*?"
+ @"bitrate=\s*(?<bitrate>[\d.]+)kbits/s",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>Returns null for non-progress lines (errors, warnings, banner).</summary>
public static FfmpegProgress? TryParse(string line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var m = Line.Match(line);
if (!m.Success) return null;
var frame = long.Parse(m.Groups["frame"].Value);
var fps = double.Parse(m.Groups["fps"].Value, System.Globalization.CultureInfo.InvariantCulture);
var size = long.Parse(m.Groups["size"].Value);
var bitrate = double.Parse(m.Groups["bitrate"].Value, System.Globalization.CultureInfo.InvariantCulture);
var hours = int.Parse(m.Groups["time"].Value);
var minutes = int.Parse(m.Groups["min"].Value);
var seconds = double.Parse(m.Groups["sec"].Value, System.Globalization.CultureInfo.InvariantCulture);
var duration = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
return new FfmpegProgress(frame, fps, bitrate, duration, size * 1024);
}
}
+272
View File
@@ -0,0 +1,272 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Models;
using ytLive.Services.Compositor;
namespace ytLive.Services.Encoder;
/// <summary>
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
/// managers + compositor and the encoder. While live it snapshots the active
/// scene each tick, resolves every element to its latest frame, composites it
/// into the tier's output frame, and paces frames into the encoder at the tier's
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
/// the capture managers and is fully hermetic in tests.
///
/// The RTMP URL comes from the options provider: until the live-stream create
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
/// without actually pushing.
/// </summary>
public sealed class FramePump : IDisposable
{
private readonly Func<Scene?> _sceneProvider;
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
private readonly Func<CompositorOptions> _compositorOptions;
private readonly Func<EncoderOptions?> _encoderOptions;
private readonly Func<IFfmpegEncoder> _encoderFactory;
private readonly Action<string>? _log;
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
private readonly SceneCompositor _compositor = new();
private readonly object _gate = new();
private IFfmpegEncoder? _encoder;
private CancellationTokenSource? _cts;
private Task? _pumpTask;
private bool _started;
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
public event EventHandler<StreamHealth>? HealthUpdated;
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
public event EventHandler<string>? Failed;
public FramePump(
Func<Scene?> sceneProvider,
Func<SceneElement, VideoFrame?> frameResolver,
Func<CompositorOptions> compositorOptions,
Func<EncoderOptions?> encoderOptions,
Func<IFfmpegEncoder> encoderFactory,
Action<string>? log = null,
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
_log = log;
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
_socialBar = socialBar;
}
public bool IsRunning { get; private set; }
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
/// so the VM can fire-and-forget it from a sync command handler.</summary>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
lock (_gate)
{
if (_started) return;
_started = true;
}
IFfmpegEncoder? encoder = null;
try
{
var options = _encoderOptions();
if (options == null)
{
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
lock (_gate) _started = false;
return;
}
encoder = _encoderFactory();
encoder.HealthUpdated += OnHealthUpdated;
encoder.ProcessFailed += OnProcessFailed;
await encoder.StartAsync(options, cancellationToken);
lock (_gate)
{
_encoder = encoder;
}
// IsRunning must be true before the loop starts: the loop reads it on
// its first iteration, and with a completed-task delay it can run
// synchronously on this thread before PumpAsync even returns.
IsRunning = true;
_cts = new CancellationTokenSource();
_pumpTask = PumpAsync(options, _cts.Token);
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
}
catch (Exception ex)
{
_log?.Invoke($"FramePump: start failed: {ex.Message}");
if (encoder != null)
{
encoder.HealthUpdated -= OnHealthUpdated;
encoder.ProcessFailed -= OnProcessFailed;
try
{
encoder.Dispose();
}
catch (Exception disposeEx)
{
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
}
}
lock (_gate)
{
_started = false;
IsRunning = false;
}
Failed?.Invoke(this, ex.Message);
}
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
IFfmpegEncoder? encoder;
Task? pump;
lock (_gate)
{
if (!_started && _encoder == null) return;
_started = false;
IsRunning = false;
encoder = _encoder;
pump = _pumpTask;
_cts?.Cancel();
}
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
// write stuck on pipe backpressure, otherwise the pump could await forever.
if (encoder != null)
{
try
{
await encoder.StopAsync(cancellationToken);
}
catch (Exception ex)
{
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
}
}
if (pump != null)
{
try
{
await pump;
}
catch (Exception ex)
{
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
}
}
if (encoder != null)
{
encoder.HealthUpdated -= OnHealthUpdated;
encoder.ProcessFailed -= OnProcessFailed;
try
{
encoder.Dispose();
}
catch (Exception ex)
{
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
}
}
lock (_gate)
{
_encoder = null;
_cts = null;
_pumpTask = null;
}
_log?.Invoke("FramePump stopped");
}
public void Dispose()
{
try
{
StopAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
}
}
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
{
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
try
{
while (!ct.IsCancellationRequested)
{
var scene = _sceneProvider();
if (scene != null)
{
var compositorOptions = _compositorOptions();
VideoFrame? socialBarFrame = null;
var socialBarTop = 0;
if (_socialBar != null)
{
var (barFrame, position) = _socialBar();
socialBarFrame = barFrame;
if (barFrame != null)
socialBarTop = position == SocialBarPosition.Top
? 0
: compositorOptions.SourceRectHeight - barFrame.Height;
}
var frame = _compositor.Render(
scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
await encoder.SubmitFrameAsync(frame, ct);
}
await _pacingDelay(interval, ct);
}
}
catch (OperationCanceledException)
{
// normal stop
}
catch (Exception ex)
{
// A failure while the pump is supposed to run (encoder died under us,
// scene provider faulted, ...) stops the pump and surfaces once.
if (ct.IsCancellationRequested)
{
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
}
else
{
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
Failed?.Invoke(this, ex.Message);
}
}
finally
{
lock (_gate) IsRunning = false;
}
}
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
private void OnProcessFailed(object? sender, string message)
{
_log?.Invoke($"FramePump: encoder process failed: {message}");
Failed?.Invoke(this, message);
_ = StopAsync();
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services.Encoder;
/// <summary>
/// Seam around a spawned subprocess (the encoder's ffmpeg and the encoder probe).
/// Exposes the redirected stdin (binary frames), stdout (probe listing) and stderr
/// (progress lines) plus exit control, so the encoder logic never touches
/// <c>System.Diagnostics.Process</c> directly and tests can fake the whole thing.
/// </summary>
public interface IEncoderProcess : IDisposable
{
void Start(ProcessStartInfo startInfo);
/// <summary>Raw binary stdin — the encoder writes BGRA frames here.</summary>
Stream StandardInput { get; }
TextReader StandardOutput { get; }
TextReader StandardError { get; }
bool HasExited { get; }
int ExitCode { get; }
void Kill();
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
+25
View File
@@ -0,0 +1,25 @@
using ytLive.Models;
namespace ytLive.Services.Encoder;
/// <summary>
/// The live encoder seam (TASK 4 ship step 3): start an FFmpeg subprocess that
/// encodes raw BGRA frames from stdin and pushes FLV to an RTMP ingestion URL,
/// raising parsed health stats from stderr. The frame producer (compositor →
/// capture managers, TASK 4 ship step 5) feeds <see cref="SubmitFrameAsync"/> at
/// capture rate; this service serializes writes, parses progress, and tears the
/// process down gracefully. Constructor-injected <see cref="IFfmpegLocator"/> +
/// process factory keep it hermetic (tests fake both).
/// </summary>
public interface IFfmpegEncoder : IDisposable
{
/// <summary>Fires on each parsed ffmpeg <c>-stats</c> progress line (~2 Hz).</summary>
event EventHandler<StreamHealth>? HealthUpdated;
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
event EventHandler<string>? ProcessFailed;
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
}
+46
View File
@@ -0,0 +1,46 @@
namespace ytLive.Services;
/// <summary>
/// Default <see cref="IGameAudioDetector"/>: samples a full-screen monitor
/// provider (the existing <c>IFullScreenDetector</c>) and the live loopback
/// level and advances a <see cref="GameAudioHysteresis"/>. The pure transitions
/// live in GameAudioHysteresis (unit-tested); this wrapper owns the providers
/// and the raised-changed event. WPF-free — the VM owns the poll timer.
/// </summary>
public sealed class GameAudioDetector : IGameAudioDetector
{
private const float SoundFloor = 0.005f;
private readonly Func<int?> _foregroundFullScreenMonitorProvider;
private readonly Func<float> _loopbackLevelProvider;
private readonly Func<DateTime> _now;
private readonly GameAudioHysteresis _hysteresis = new();
/// <param name="foregroundFullScreenMonitorProvider">Returns the monitor a
/// full-screen foreground window covers, or null when windowed/none.</param>
/// <param name="loopbackLevelProvider">Current smoothed desktop/game level (0..1).</param>
/// <param name="now">Clock for the hysteresis windows; injectable for tests.</param>
public GameAudioDetector(
Func<int?> foregroundFullScreenMonitorProvider,
Func<float> loopbackLevelProvider,
Func<DateTime>? now = null)
{
_foregroundFullScreenMonitorProvider = foregroundFullScreenMonitorProvider;
_loopbackLevelProvider = loopbackLevelProvider;
_now = now ?? (() => DateTime.Now);
}
public bool IsGameAudioActive => _hysteresis.IsActive;
public event Action<bool>? IsGameAudioActiveChanged;
public void Poll()
{
var before = _hysteresis.IsActive;
var isFullScreen = _foregroundFullScreenMonitorProvider() != null;
var hasSound = _loopbackLevelProvider() > SoundFloor;
_hysteresis.Update(isFullScreen, hasSound, _now());
if (before != _hysteresis.IsActive)
IsGameAudioActiveChanged?.Invoke(_hysteresis.IsActive);
}
}
+57
View File
@@ -0,0 +1,57 @@
namespace ytLive.Services;
/// <summary>
/// Pure show/hide state machine for the game audio bar (TASK 4 game audio).
/// SHOW: a full-screen foreground app has been producing desktop audio for at
/// least <see cref="ShowAfterSound"/> — "a working game with sound". HIDE: the
/// app leaves fullscreen/foreground for <see cref="HideAfterWindowed"/> — the
/// game is no longer up in the preview. Silence NEVER hides an active bar; it
/// only matters for the initial show. No timers; <see cref="Update"/> is fed by
/// the caller with a clock.
/// </summary>
public sealed class GameAudioHysteresis
{
private readonly TimeSpan _showAfterSound = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan _hideAfterWindowed = TimeSpan.FromSeconds(1);
private DateTime? _soundSince;
private DateTime? _windowedSince;
public bool IsActive { get; private set; }
public void Update(bool isFullScreen, bool hasSound, DateTime now)
{
if (isFullScreen)
{
_windowedSince = null;
if (IsActive)
return;
if (!hasSound)
{
_soundSince = null;
return;
}
_soundSince ??= now;
if (now - _soundSince >= _showAfterSound)
IsActive = true;
}
else
{
_soundSince = null;
if (!IsActive)
{
_windowedSince = null;
return;
}
_windowedSince ??= now;
if (now - _windowedSince >= _hideAfterWindowed)
{
IsActive = false;
_windowedSince = null;
}
}
}
}
+9
View File
@@ -8,7 +8,16 @@ namespace ytLive.Services;
public interface ICameraFrameSource public interface ICameraFrameSource
{ {
string DeviceId { get; } string DeviceId { get; }
/// <summary>Normalized BGRA frames from a worker thread; callers marshal to the UI thread.</summary>
event Action<VideoFrame>? FrameAvailable; 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 StartAsync();
Task StopAsync(); Task StopAsync();
} }
+20
View File
@@ -0,0 +1,20 @@
namespace ytLive.Services;
/// <summary>
/// Detects "a working game with sound is up in the preview" (TASK 4 game audio
/// bar): becomes active once a full-screen foreground app is producing desktop
/// audio, and stays active as long as that full-screen app remains — silence
/// never hides the bar, only the game leaving fullscreen/foreground does. Seam
/// so the VM and tests never touch Win32 or audio interop directly.
/// </summary>
public interface IGameAudioDetector
{
/// <summary>True while the game audio bar should be visible.</summary>
bool IsGameAudioActive { get; }
/// <summary>Raised when the bar should appear or disappear.</summary>
event Action<bool>? IsGameAudioActiveChanged;
/// <summary>Samples the injected providers and advances the state machine.</summary>
void Poll();
}
+175 -5
View File
@@ -15,6 +15,9 @@ public class LayoutStore : IDisposable
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary> /// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
public Webcam? Webcam { get; private set; } 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) public LayoutStore(string path)
{ {
ActivePath = path; ActivePath = path;
@@ -104,6 +107,23 @@ public class LayoutStore : IDisposable
PRIMARY KEY (SceneId, WebcamId) 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) foreach (var sql in statements)
{ {
@@ -114,11 +134,14 @@ public class LayoutStore : IDisposable
MigrateSourceTable(); MigrateSourceTable();
MigrateWebcamConfigTable(); MigrateWebcamConfigTable();
MigrateSceneTable(); MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3) if (GetUserVersion() < 3)
MigrateToV3(); MigrateToV3();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "PRAGMA user_version = 6;"; cmd.CommandText = "PRAGMA user_version = 8;";
cmd.ExecuteNonQuery(); 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() public List<Scene> Load()
{ {
Webcam = null; Webcam = null;
Socials = null;
var scenes = new List<Scene>(); var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>(); var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>(); var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
using (var cmd = _connection.CreateCommand()) 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(); using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
@@ -339,6 +423,7 @@ public class LayoutStore : IDisposable
IsHidden = reader.GetInt32(2) != 0, IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0, IsChatScene = reader.GetInt32(3) != 0,
HasBackdrop = reader.GetInt32(4) != 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()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = """ cmd.CommandText = """
@@ -444,7 +559,7 @@ public class LayoutStore : IDisposable
return scenes; 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 tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
@@ -466,6 +581,18 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
using (var cmd = _connection.CreateCommand()) 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.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx; cmd.Transaction = tx;
@@ -475,8 +602,8 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder) INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort) VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); 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 hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer); var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
var backdropP = cmd.Parameters.Add("$hasBackdrop", 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 sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0; var sort = 0;
@@ -494,6 +622,7 @@ public class LayoutStore : IDisposable
hiddenP.Value = scene.IsHidden ? 1 : 0; hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0; chatP.Value = scene.IsChatScene ? 1 : 0;
backdropP.Value = scene.HasBackdrop ? 1 : 0; backdropP.Value = scene.HasBackdrop ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
sortP.Value = sort++; sortP.Value = sort++;
cmd.ExecuteNonQuery(); 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()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);"; cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
+148 -40
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime; using System.Runtime.InteropServices.WindowsRuntime;
@@ -6,6 +7,7 @@ using System.Threading.Tasks;
using Windows.Graphics.Imaging; using Windows.Graphics.Imaging;
using Windows.Media.Capture; using Windows.Media.Capture;
using Windows.Media.Capture.Frames; using Windows.Media.Capture.Frames;
using Windows.Media.Devices;
using Windows.Media.MediaProperties; using Windows.Media.MediaProperties;
using ytLive.Helpers; using ytLive.Helpers;
@@ -16,17 +18,28 @@ namespace ytLive.Services;
/// preview source; the capture pipeline does any format conversion, so every /// preview source; the capture pipeline does any format conversion, so every
/// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a /// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a
/// worker thread — marshal before touching WPF. /// 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> /// </summary>
public sealed class MediaCaptureFrameSource : ICameraFrameSource public sealed class MediaCaptureFrameSource : ICameraFrameSource
{ {
private readonly string _deviceId; private readonly string _deviceId;
private MediaCapture? _capture; private MediaCapture? _capture;
private MediaFrameReader? _frameReader; private MediaFrameReader? _frameReader;
private string? _lastError;
private bool _isFailed;
public string DeviceId => _deviceId; public string DeviceId => _deviceId;
public event Action<VideoFrame>? FrameAvailable; public event Action<VideoFrame>? FrameAvailable;
public event Action<string>? SourceFailed;
public MediaCaptureFrameSource(string deviceId) public MediaCaptureFrameSource(string deviceId)
{ {
_deviceId = deviceId; _deviceId = deviceId;
@@ -34,47 +47,15 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
public async Task StartAsync() public async Task StartAsync()
{ {
var capture = new MediaCapture(); // Fallback ladder: prefer the camera's VideoPreview stream, but if the
MediaFrameReader? reader = null; // reader refuses to start there (NoVideoFrameAvailable), retry against
try // 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 if (!await TryStartAsync(preferVideoRecord: true))
{
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());
throw new InvalidOperationException( throw new InvalidOperationException(
$"Camera '{_deviceId}' exposes no video source (found: {kinds}). " + _lastError ?? $"Camera '{_deviceId}' could not be started.");
"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;
} }
} }
@@ -98,7 +79,134 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
var capture = _capture; var capture = _capture;
_capture = null; _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) private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
+9
View File
@@ -156,6 +156,15 @@ public sealed class ScreenCaptureManager : IDisposable
toStop.PreviewBitmap = null; toStop.PreviewBitmap = null;
} }
/// <summary>The most recent frame for a capture key, or null before the first
/// frame arrives (or if the key has no session). The live compositor reads
/// the backdrop from here.</summary>
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
public void Dispose() public void Dispose()
{ {
List<CaptureSession> sessions; List<CaptureSession> sessions;
+243
View File
@@ -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;
}
}
+26 -5
View File
@@ -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 | | `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 | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | | `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)` | | `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
| `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device | | `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device |
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) | | `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) | | `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 | | `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. `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 | | `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()` | | `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 | | `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) | | `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
@@ -27,12 +27,33 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` | | `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation | | `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) | | `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" | | `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H | | `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) | | `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) | | `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it | | `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) | | `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo``software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight bar height`. See `ai.md` "Live frame pipeline" |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter``MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead |
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+128
View File
@@ -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="&#x270F;&#xFE0F;" 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="&#x1F5D1;&#xFE0F;" 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>
+73
View File
@@ -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);
}
}
+216 -28
View File
@@ -104,15 +104,19 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
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 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 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…" 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. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse 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. ✅ **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) 11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
12. ✅ 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) 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. **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending 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. **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1) 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. ☐ **Text source**live text ("Starting soon", "Back in 5", handle, callout) 15. ☐ **Window capture**absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
16. ☐ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video 16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
17. **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build 17. **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
18. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`) 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) ### The Minimal Source Set (design decision — do not expand casually)
@@ -197,10 +201,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10) 1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10) 2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
3. **Encoder + RTMP push** — the FFmpeg subprocess: frames via stdin, stderr health parsing, FLV mux + push to the cached reusable stream's ingestion URL 3. **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
4. **WASAPI audio capture** loopback (desktop/game) + the picked mic feeding `AudioLevel` so the realtime meter comes alive (req 7) 4. **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
5. **Frame-pipeline wiring**`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder 5. **Frame-pipeline wiring SHIPPED** (2026-08-12)`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
6. **Health stats** bitrate, FPS, dropped frames reported live in the bottom bar (req 5) 6. **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable) 7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
@@ -334,8 +338,8 @@ provides one.
1. `IFfmpegLocator.cs` — the seam. 1. `IFfmpegLocator.cs` — the seam.
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`. 2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build 3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
output and surfaced via the top-bar **About** button (`MainWindow` code-behind, opens the file in the output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
OS viewer). viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and **Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
@@ -353,12 +357,173 @@ parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health sta
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the **Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a **lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output and is surfaced by a new staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**. guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**. cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
ingestion URL — one battle-tested subprocess doing encode + mux + push + reconnect, the app feeding
frames via stdin and parsing stderr for health (req 2).
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
whole subprocess (probe + encoder), no real binary.
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
on a non-zero unexpected exit.
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
**Decisions (locked):**
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
`StopStream``Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
GUID. Trailing partial samples are ignored.
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
`MicLevelChanged``AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
extensible/truncation) — **139 passing**.
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
**Decisions (locked via user Q&A, 2026-08-12):**
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
WASAPI samples into the encoder's AAC track is its own later step.
2. **RTMP URL via a provider seam**`MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
null today (the reusable stream's ingest URL lands with TASK 5); when it yields null the pump logs
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
**Design:**
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
backdrop's live frame for the compositor.
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig``GetLatestFrame(WebcamId)`;
`Source.IsLiveCapture``GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
the pump fails while live (minimal — detailed health surfacing is ship step 6).
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
exception is contained (logged + `Failed` + pump stops) rather than crashing.
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition`
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
value on a property the binding owns.
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
`FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
later re-saves the entry with the icon fixed, the name persists with it.
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
`SourceRectHeight bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
load/save/notify) and feeds the seam.
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
(`SocialBarSnap`) were removed with the click-toggle supersession.
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
--- ---
## TASK 5 — YouTube Live Stream Management ## TASK 5 — YouTube Live Stream Management
@@ -399,12 +564,12 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
### Status: ✅ Done ### Status: ✅ Done
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v6**) 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 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 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 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 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 → v6 — 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); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned 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 ### Design decisions
@@ -418,16 +583,19 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active. **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 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. 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, PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
`user_version` **6** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
`Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey` for the live-capture `ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
backdrop; v6 = `Scene.HasBackdrop` — the backdrop is **Live-only by policy** v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
(one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop + `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` the backdrop is
sources; `EnforceBackdropPolicy` re-normalizes every load). `WindowHandle` stays in-memory **Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
(per-session). Save = transactional rewrite; orphaned assets pruned. 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 6. **Startup** — load the active file; seed the five canonical scenes
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+) (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; button re-adds a missing canonical scene and is hidden once all five are present;
@@ -435,6 +603,26 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa
--- ---
## TASK 7 — UI polish batch (scenes/sources rows, dedup naming, social bar)
**Goal:** clean up the two side lists and the social bar per gramps's review.
### Status: ✅ Done
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
### Design decisions
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
---
## Backlog (future versions) ## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+23 -3
View File
@@ -7,9 +7,10 @@ its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
are NOT optional: they attach because this product ships or automates the are NOT optional: they attach because this product ships or automates the
download of these components. download of these components.
If this file changes, update it here AND in the app's About screen (it opens If this file changes, update it here AND in the app's About screen (the About
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
the guardrails — the "never do" list is there on purpose. ai.md ("Licensing — do not violate") for the guardrails — the "never do" list is
there on purpose.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@@ -68,6 +69,25 @@ the guardrails — the "never do" list is there on purpose.
License: MIT License: MIT
Home: https://github.com/dotnet/ 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 MISCELLANEOUS
+1
View File
@@ -43,6 +43,7 @@
<Style x:Key="IconButton" TargetType="Button"> <Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#d0d0d0"/>
<Setter Property="BorderThickness" Value="0"/> <Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/> <Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
+661 -51
View File
@@ -4,6 +4,7 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Interop; using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
@@ -13,6 +14,9 @@ using Microsoft.Win32;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
@@ -23,6 +27,8 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeChatService _youtubeChat; private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer; private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _gameAudioTimer;
private Scene? _activeScene; private Scene? _activeScene;
private SceneElement? _selectedElement; private SceneElement? _selectedElement;
@@ -37,6 +43,14 @@ public class MainViewModel : ViewModelBase
private bool _micMuted; private bool _micMuted;
private double? _volumeBeforeMute; private double? _volumeBeforeMute;
private string? _micSourceName; private string? _micSourceName;
private MicStatus _micStatus = MicStatus.NotConnected;
private double _gameAudioLevel;
private bool _gameVolumeAdjusting;
private bool _gameVolumeFlash;
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
private bool _isGameAudioBarVisible;
private StreamStatus _streamStatus = StreamStatus.Offline; private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new(); private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty; private string _streamTitle = string.Empty;
@@ -47,7 +61,7 @@ public class MainViewModel : ViewModelBase
private string _previewGlowBrush = "Transparent"; private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0); private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00"; private string _liveElapsedText = "00:00:00";
private double _livePulseOpacity = 1.0; private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed; private TimeSpan _liveElapsed;
private bool _isSettingsOpen; private bool _isSettingsOpen;
@@ -74,15 +88,32 @@ public class MainViewModel : ViewModelBase
private readonly ICameraEnumerator _cameraEnumerator; private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager; private readonly CameraManager _cameraManager;
private Webcam? _webcam; private Webcam? _webcam;
private string? _webcamError;
private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame;
private readonly IMicrophoneEnumerator _microphoneEnumerator; private readonly IMicrophoneEnumerator _microphoneEnumerator;
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
// lifetime (started at startup, stopped on shutdown) so the footer meters
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
// feeds the game audio bar's meter. Private by design — the mixer surfaces
// the levels + mic connection state to the UI.
private readonly AudioMixer _audioMixer;
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func<string?> _rtmpUrlProvider;
private readonly FramePump _framePump;
// Screen backdrop: a permanent live capture (desktop/game) that every scene // Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the // shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager. // ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector; private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly IGameAudioDetector _gameAudioDetector;
private int? _lastForegroundFullScreenMonitor; private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts; private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage; private ImageSource? _backdropImage;
@@ -168,9 +199,50 @@ public class MainViewModel : ViewModelBase
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary> /// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null; 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> /// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig; 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 public StreamStatus StreamStatus
{ {
get => _streamStatus; get => _streamStatus;
@@ -180,7 +252,10 @@ public class MainViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(LiveIndicatorVisible)); OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -213,8 +288,9 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _accountDisplayName, value); private set => SetProperty(ref _accountDisplayName, value);
} }
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's // ─── Audio: the mic bar (meter + volume + mute + status dot) is always
// ─── only audio control is the mic: meter + volume + mute.) ─── // ─── visible; the game bar (meter + volume + mute) appears only while a
// ─── full-screen game is producing sound. Both meters preview live. ───
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture /// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary> /// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
@@ -236,7 +312,7 @@ public class MainViewModel : ViewModelBase
/// level flashes on the bar) so the creator sees where they're setting it; /// level flashes on the bar) so the creator sees where they're setting it;
/// otherwise the realtime live level scaled by volume (raising the volume /// otherwise the realtime live level scaled by volume (raising the volume
/// moves ambient noise up the bar). Read-only meter.</summary> /// moves ambient noise up the bar). Read-only meter.</summary>
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevel * MicVolume); private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume);
public double MeterFillWidth => MeterLevel * 288; public double MeterFillWidth => MeterLevel * 288;
@@ -350,12 +426,178 @@ public class MainViewModel : ViewModelBase
Owner = System.Windows.Application.Current?.MainWindow Owner = System.Windows.Application.Current?.MainWindow
}; };
if (dialog.ShowDialog() == true && dialog.PickedDevice != null) if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName; MicSourceName = dialog.PickedDevice.DisplayName;
_audioMixer.RestartMic(); // swap the live device immediately
}
}
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// <summary>Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).</summary>
public SolidColorBrush MicStatusBrush => MicStatus switch
{
MicStatus.Connected => BarOnBrush,
MicStatus.Problem => MicProblemBrush,
_ => BarOffBrush,
};
public string MicStatusToolTip => MicStatus switch
{
MicStatus.Connected => "Mic connected — click to change",
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
_ => "No mic connected — click to choose a microphone",
};
// ─── Game audio bar (desktop/game): visible only while a full-screen game
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
// ─── the mic bar. ───
/// <summary>True while the game audio bar should be shown (driven by
/// IGameAudioDetector via the poll timer).</summary>
public bool IsGameAudioBarVisible
{
get => _isGameAudioBarVisible;
private set => SetProperty(ref _isGameAudioBarVisible, value);
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Displayed game meter level: 0 while muted; the volume position
/// while the slider is dragged (or briefly after an unmute flash); otherwise
/// the realtime live level scaled by volume.</summary>
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
public double GameMeterFillWidth => GameMeterLevel * 288;
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public void SetGameVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelGameVolumeFlash();
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void BeginGameVolumeFlash()
{
_gameVolumeFlash = true;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
_gameVolumeFlashTimer.Stop();
_gameVolumeFlashTimer.Start();
}
private void EndGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
if (_gameVolumeFlash)
{
_gameVolumeFlash = false;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void CancelGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
_gameVolumeFlash = false;
}
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.</summary>
public double GameAudioVolume
{
get => _gameVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_gameMuted)
_gameVolumeBeforeMute ??= _gameVolume;
if (SetProperty(ref _gameVolume, clamped))
{
var muted = clamped == 0;
if (_gameMuted != muted)
{
_gameMuted = muted;
OnPropertyChanged(nameof(GameMuted));
OnPropertyChanged(nameof(GameMuteText));
}
if (!muted)
_gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).</summary>
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
private void ToggleGameMute()
{
if (GameMuted)
{
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
BeginGameVolumeFlash();
}
else
{
_gameVolumeBeforeMute = GameAudioVolume;
GameAudioVolume = 0;
}
} }
public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive; public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline; public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
@@ -436,7 +678,15 @@ public class MainViewModel : ViewModelBase
public string StreamVisibility public string StreamVisibility
{ {
get => _streamVisibility; get => _streamVisibility;
set => SetProperty(ref _streamVisibility, value); set
{
if (SetProperty(ref _streamVisibility, value))
{
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(IsLivePrivate));
}
}
} }
public string WindowTitle public string WindowTitle
@@ -469,12 +719,6 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _liveElapsedText, value); set => SetProperty(ref _liveElapsedText, value);
} }
public double LivePulseOpacity
{
get => _livePulseOpacity;
set => SetProperty(ref _livePulseOpacity, value);
}
// Overlay panels // Overlay panels
public bool IsSettingsOpen public bool IsSettingsOpen
{ {
@@ -687,20 +931,21 @@ public class MainViewModel : ViewModelBase
// Commands // Commands
public ICommand AddSceneCommand { get; } public ICommand AddSceneCommand { get; }
public ICommand EditSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
public ICommand ToggleSceneVisibilityCommand { get; }
public ICommand AddSourceCommand { get; } public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; } public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; } public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; } public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; } public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; } public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; } public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; } public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; } public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
@@ -735,6 +980,9 @@ public class MainViewModel : ViewModelBase
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash(); _volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; _saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow(); _saveDebounce.Tick += (_, _) => SaveLayoutNow();
@@ -746,19 +994,20 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged; Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty)); AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter)); AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync()); AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage()); AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement)); RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam); ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene); ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync()); ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture()); RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo)); SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute()); ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone()); OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings")); OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug")); OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
@@ -783,12 +1032,31 @@ public class MainViewModel : ViewModelBase
id => new MediaCaptureFrameSource(id), id => new MediaCaptureFrameSource(id),
System.Windows.Application.Current?.Dispatcher); System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged; _cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_cameraManager.CameraFailed += OnCameraFailed;
_microphoneEnumerator = new WinRtMicrophoneEnumerator(); _microphoneEnumerator = new WinRtMicrophoneEnumerator();
_audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message));
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector(); _fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays()) foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display); Displays.Add(display);
_gameAudioDetector = new GameAudioDetector(
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
() => (float)GameAudioLevel);
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_gameAudioTimer.Tick += OnGameAudioPollTick;
_gameAudioTimer.Start();
_screenCaptureFactory = new ScreenCaptureSourceFactory( _screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle); () => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager( _screenCaptureManager = new ScreenCaptureManager(
@@ -798,6 +1066,18 @@ public class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += (key, message) => _screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => ActiveScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout(); LoadLayout();
_ = LoadSavedSessionAsync(); _ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end"); AppLog.Write("MainViewModel ctor end");
@@ -888,6 +1168,12 @@ public class MainViewModel : ViewModelBase
UpdateBackdropImage(); UpdateBackdropImage();
ReacquireWebcam(); ReacquireWebcam();
ReacquireScreenCaptures(); ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave(); ScheduleSave();
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
@@ -921,15 +1207,25 @@ public class MainViewModel : ViewModelBase
} }
// CameraManager creates the shared WriteableBitmap on the UI thread at the // 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) private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{ {
if (_webcam?.DeviceId != deviceId) return; if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
foreach (var scene in Scenes) foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>()) foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap; 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) ─── // ─── Screen backdrop capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:"; private const string MonitorKeyPrefix = "monitor:";
@@ -1096,6 +1392,9 @@ public class MainViewModel : ViewModelBase
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
SaveLayoutNow(); SaveLayoutNow();
_gameAudioTimer.Stop();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose(); _cameraManager.Dispose();
_screenCaptureManager.Dispose(); _screenCaptureManager.Dispose();
_layoutStore.Dispose(); _layoutStore.Dispose();
@@ -1106,7 +1405,7 @@ public class MainViewModel : ViewModelBase
_saveDebounce?.Stop(); _saveDebounce?.Stop();
try try
{ {
_layoutStore.Save(Scenes, _webcam); _layoutStore.Save(Scenes, _webcam, _socials);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1223,26 +1522,16 @@ public class MainViewModel : ViewModelBase
ActiveScene = scene; ActiveScene = scene;
} }
private void BeginEditScene(Scene? scene) private void BeginEditElement(SceneElement? element)
{ {
if (scene == null) return; if (element == null) return;
scene.IsEditing = true; element.IsEditing = true;
} }
private void ToggleSceneVisibility(Scene? scene) private void ToggleElementVisibility(SceneElement? element)
{ {
if (scene == null) return; if (element == null) return;
scene.IsHidden = !scene.IsHidden; element.IsVisible = !element.IsVisible;
if (scene.IsHidden && ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
}
private void RemoveScene(Scene? scene)
{
if (scene == null) return;
Scenes.Remove(scene);
if (ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault();
} }
private void AddSource(object? parameter) private void AddSource(object? parameter)
@@ -1285,15 +1574,30 @@ public class MainViewModel : ViewModelBase
return; return;
} }
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType); scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
scene.Elements.Add(new Source { Name = name, Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
} }
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
private static string NextSourceName(Scene scene, string baseName)
{
var taken = scene.Elements.OfType<Source>()
.Select(s => s.Name)
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|| (n.Length > baseName.Length
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(n.Substring(baseName.Length), out _)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!taken.Contains(baseName)) return baseName;
for (var i = 2; ; i++)
if (!taken.Contains($"{baseName}{i}"))
return $"{baseName}{i}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the // Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous // cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while // camera (which is what happened after deleting one scene's webcam while
@@ -1348,7 +1652,7 @@ public class MainViewModel : ViewModelBase
if (!started) if (!started)
{ {
MessageBox.Show( 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); "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
} }
} }
@@ -1372,7 +1676,7 @@ public class MainViewModel : ViewModelBase
if (!started) if (!started)
{ {
MessageBox.Show( 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); "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
break; break;
} }
@@ -1508,10 +1812,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return; if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image); var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId); var image = ImageCache.Get(assetId);
if (image != null) if (image != null)
@@ -1665,6 +1966,8 @@ public class MainViewModel : ViewModelBase
? "ytLlive" ? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive"; : $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming; StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
} }
} }
@@ -1672,6 +1975,10 @@ public class MainViewModel : ViewModelBase
{ {
StreamStatus = StreamStatus.Offline; StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive"; WindowTitle = "ytLlive";
ResetHealth(StreamStatus.Offline);
// Audio capture is always-on (preview monitoring); only the frame pump
// and the session stop here.
_ = _framePump.StopAsync();
// Graceful end completes the session = signs out (the DPAPI token is // Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash // cleared so the next Start Stream requires a fresh sign-in). A crash
// never runs this, so the token survives and the creator stays signed in. // never runs this, so the token survives and the creator stays signed in.
@@ -1682,6 +1989,170 @@ public class MainViewModel : ViewModelBase
AppLog.Write("Stream ended; session signed out"); AppLog.Write("Stream ended; session signed out");
} }
private void OnMicLevelChanged(float level)
{
// NAudio raises on its capture thread; marshal to the UI thread so the
// meter binding updates safely.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => AudioLevel = level);
else
AudioLevel = level;
}
private void OnLoopbackLevelChanged(float level)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => GameAudioLevel = level);
else
GameAudioLevel = level;
}
private void OnMicConnected()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
else
MicStatus = MicStatus.Connected;
}
private void OnMicFailed(Exception ex)
{
// The mixer logs the failure detail; here we only flip the dot to yellow.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
else
MicStatus = MicStatus.Problem;
}
/// <summary>Starts capture once at startup: with no mic device present the
/// dot stays red and capture never starts; otherwise the mixer starts and
/// raises MicConnected (green) or MicFailed (yellow).</summary>
private async Task StartMicCaptureAsync()
{
try
{
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
if (mics.Count == 0)
{
MicStatus = MicStatus.NotConnected;
AppLog.Write("Mic: no capture devices found — mic capture not started");
return;
}
_audioMixer.Start();
}
catch (Exception ex)
{
AppLog.Write($"Mic: device check failed: {ex.Message}");
}
}
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
private void OnGameAudioPollTick(object? sender, EventArgs e)
{
try
{
_gameAudioDetector.Poll();
}
catch (Exception ex)
{
AppLog.Write($"Game audio detection failed: {ex.Message}");
}
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
{
return element switch
{
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
SourceRectY = (int)Math.Round(OutputRectY),
SourceRectWidth = (int)Math.Round(OutputRectWidth),
SourceRectHeight = (int)Math.Round(OutputRectHeight),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
if (string.IsNullOrWhiteSpace(url)) return null;
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url,
Width = quality.Width,
Height = quality.Height,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
private void OnFramePumpFailed(object? sender, string message)
{
AppLog.Write($"Frame pump failed: {message}");
if (IsLive) StreamStatus = StreamStatus.Error;
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals() private void UpdateLiveVisuals()
{ {
var live = IsLive; var live = IsLive;
@@ -1693,7 +2164,8 @@ public class MainViewModel : ViewModelBase
{ {
_liveElapsed = TimeSpan.Zero; _liveElapsed = TimeSpan.Zero;
LiveElapsedText = "00:00:00"; LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0; _recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
_liveTimer.Start(); _liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer(); if (BrandFlashEnabled) StartBrandFlashTimer();
} }
@@ -1704,7 +2176,8 @@ public class MainViewModel : ViewModelBase
_brandFlashOffTimer.Stop(); _brandFlashOffTimer.Stop();
BrandFlashActive = false; BrandFlashActive = false;
LiveElapsedText = "00:00:00"; LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0; _recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
} }
} }
@@ -1737,6 +2210,143 @@ public class MainViewModel : ViewModelBase
{ {
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1)); _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss"); LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0; _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
// ─── Social bar ───
/// <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;
} }
} }
+518
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+177 -6
View File
@@ -89,9 +89,9 @@ C# / WPF (.NET 8) following MVVM:
| Path | Role | | Path | Role |
|------|------| |------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage | | `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 | | `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
| `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)** | | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) | | `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
@@ -100,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
- `ViewModelBase.SetProperty<T>()` for property change notifications - `ViewModelBase.SetProperty<T>()` for property change notifications
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) - `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevel * MicVolume)` (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. - **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)``ToDisplay` maps the raw linear RMS onto a 60..0 dBFS display scale, because real speech sits around 40..20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp``ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext) - ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet - Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available) - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
@@ -112,9 +112,9 @@ C# / WPF (.NET 8) following MVVM:
### Current limitations / TODOs ### 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`) - `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 - `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED** — full plan in `TASKS.md`; the encoder subprocess + RTMP push, audio capture, and the frame-pipeline wiring follow (each its own PR) - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1) ### Screen backdrop capture (TASK 3 ship task #1)
@@ -180,6 +180,14 @@ instead of a normal draggable source.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder - **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
game are behind the picker; picker-based captures don't survive reload. game are behind the picker; picker-based captures don't survive reload.
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred - **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task. to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders — - **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
@@ -330,6 +338,165 @@ const). Constructor-injected search dirs / tools dir / downloader (`Func<string,
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
encoder step (not yet — this PR ships the seam + impl + tests only). encoder step (not yet — this PR ships the seam + impl + tests only).
### 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 36 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`) ### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
@@ -352,6 +519,10 @@ guardrails below — written down so a future "quick fix" never reintroduces a G
— the obligations are not optional. — the obligations are not optional.
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give - **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. 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 - **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 **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. release blocker). Queued early is wrong; the release pass owns it.
+6 -3
View File
@@ -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 | | `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** | | `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 | | `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 | | `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 | | `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code |
| `Views/` | Reserved for Views; currently empty | — | | `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 ## Integration rule
Every feature change ships with its memory update: `ai.md` for architecture / Every feature change ships with its memory update **in the same commit**:
patterns, `TASKS.md` for status, index files when the layout changes. That is `ai.md` for architecture / patterns, `TASKS.md` for status, index files when
what keeps the map accurate enough to trust next session. 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.
+394
View File
@@ -0,0 +1,394 @@
using NAudio.Wave;
using Xunit;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// TASK 4 ship step 4: WASAPI audio capture behind the <see cref="IAudioSource"/>
/// seam. The units pin down the pure pieces — byte→float conversion, the level
/// meter math, and the mixer lifecycle/forwarding against fakes. No real audio
/// devices (NAudio device resolution is a thin wrapper left to a manual smoke
/// test), no timers.
/// </summary>
public class AudioMixerTests
{
private sealed class FakeSource : IAudioSource
{
public int StartCount { get; private set; }
public int StopCount { get; private set; }
public bool Disposed { get; private set; }
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
public void Start() => StartCount++;
public void Stop() => StopCount++;
public void Dispose() => Disposed = true;
public void MarkStarted() => Started?.Invoke();
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
public void Fail(Exception ex) => Failed?.Invoke(ex);
}
[Fact]
public void Start_StartsBothSources()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Start();
Assert.Equal(1, mic.StartCount);
Assert.Equal(1, loopback.StartCount);
}
[Fact]
public void Start_IsIdempotent()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
mixer.Start();
mixer.Start();
Assert.Equal(1, mic.StartCount);
}
[Fact]
public void Stop_StopsBothAndResetsLevel()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Start();
mic.Emit(new AudioSample(new[] { 0.8f }, 48000, 1));
var last = -1f;
mixer.MicLevelChanged += l => last = l;
mixer.Stop();
Assert.Equal(1, mic.StopCount);
Assert.Equal(1, loopback.StopCount);
Assert.Equal(0f, last);
Assert.Equal(0f, mixer.MicLevel);
}
[Fact]
public void Stop_WithNoStart_DoesNothing()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
mixer.Stop();
Assert.Equal(0, mic.StopCount);
}
[Fact]
public void MicSamples_DriveMicLevelChanged()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
var levels = new List<float>();
mixer.MicLevelChanged += l => levels.Add(l);
mixer.Start();
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
mic.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 1));
Assert.NotEmpty(levels);
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
}
[Fact]
public void LoopbackSamples_DoNotChangeMicLevel()
{
var loopback = new FakeSource();
var mixer = new AudioMixer(new FakeSource(), loopback);
var levels = new List<float>();
mixer.MicLevelChanged += l => levels.Add(l);
mixer.Start();
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
Assert.Empty(levels);
Assert.Equal(0f, mixer.MicLevel);
}
[Fact]
public void MicSamples_DoNotChangeLoopbackLevel()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
var levels = new List<float>();
mixer.LoopbackLevelChanged += l => levels.Add(l);
mixer.Start();
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
Assert.Empty(levels);
Assert.Equal(0f, mixer.LoopbackLevel);
}
[Fact]
public void LoopbackSamples_DriveLoopbackLevelChanged()
{
var loopback = new FakeSource();
var mixer = new AudioMixer(new FakeSource(), loopback);
var levels = new List<float>();
mixer.LoopbackLevelChanged += l => levels.Add(l);
mixer.Start();
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
Assert.NotEmpty(levels);
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
}
[Fact]
public void MicStarted_RaisesMicConnected()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
var connected = 0;
mixer.MicConnected += () => connected++;
mixer.Start();
mic.MarkStarted();
Assert.Equal(1, connected);
}
[Fact]
public void MicFailure_RaisesMicFailed()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
Exception? failed = null;
mixer.MicFailed += ex => failed = ex;
mixer.Start();
mic.Fail(new InvalidOperationException("boom"));
Assert.NotNull(failed);
Assert.Equal("boom", failed!.Message);
}
[Fact]
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Start();
mixer.RestartMic();
Assert.Equal(2, mic.StartCount);
Assert.Equal(1, mic.StopCount);
Assert.Equal(1, loopback.StartCount);
Assert.Equal(0, loopback.StopCount);
}
[Fact]
public void RestartMic_ResetsLevel()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
mixer.Start();
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
Assert.True(mixer.MicLevel > 0);
float? reset = null;
mixer.MicLevelChanged += l => reset = l;
mixer.RestartMic();
Assert.Equal(0f, reset);
}
[Fact]
public void MicFailure_LogsAndResetsLevel()
{
var mic = new FakeSource();
var logs = new List<string>();
var mixer = new AudioMixer(mic, new FakeSource(), m => logs.Add(m));
mixer.Start();
mic.Emit(new AudioSample(new[] { 0.5f }, 48000, 1));
var last = -1f;
mixer.MicLevelChanged += l => last = l;
mic.Fail(new InvalidOperationException("boom"));
Assert.Contains(logs, l => l.Contains("boom"));
Assert.Equal(0f, last);
}
[Fact]
public void LoopbackFailure_LogsButKeepsMic()
{
var loopback = new FakeSource();
var logs = new List<string>();
var mixer = new AudioMixer(new FakeSource(), loopback, m => logs.Add(m));
mixer.Start();
var last = -1f;
mixer.MicLevelChanged += l => last = l;
loopback.Fail(new InvalidOperationException("boom"));
Assert.Contains(logs, l => l.Contains("boom"));
Assert.Equal(-1f, last);
}
[Fact]
public void Dispose_StopsAndDisposesSources()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Start();
mixer.Dispose();
Assert.True(mic.Disposed);
Assert.True(loopback.Disposed);
Assert.Equal(1, mic.StopCount);
}
[Fact]
public void Dispose_UnsubscribesEvents()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Dispose();
var levels = new List<float>();
mixer.MicLevelChanged += l => levels.Add(l);
mic.Emit(new AudioSample(new[] { 1f }, 48000, 1));
Assert.Empty(levels);
}
}
public class AudioLevelMeterTests
{
[Fact]
public void ConstantSine_ConvergesToRms()
{
var meter = new AudioLevelMeter();
var sample = new AudioSample(new[] { 0.5f, -0.5f, 0.5f, -0.5f }, 48000, 1);
float level = 0;
for (var i = 0; i < 30; i++)
level = meter.Push(sample);
Assert.InRange(level, 0.45f, 0.55f);
}
[Fact]
public void Silence_DrivesTowardZero()
{
var meter = new AudioLevelMeter();
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
var samples = new float[1024];
var sample = new AudioSample(samples, 48000, 1);
for (var i = 0; i < 50; i++)
meter.Push(sample);
Assert.Equal(0f, meter.Level, 3);
}
[Fact]
public void EmptySample_KeepsLevel()
{
var meter = new AudioLevelMeter();
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
var before = meter.Level;
var level = meter.Push(new AudioSample(Array.Empty<float>(), 48000, 1));
Assert.Equal(before, level);
}
[Fact]
public void Reset_ZerosLevel()
{
var meter = new AudioLevelMeter();
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
meter.Reset();
Assert.Equal(0f, meter.Level);
}
[Fact]
public void ToDisplay_IsLogarithmic()
{
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
// -60 dBFS floor → 0, 0 dBFS → 1, and a decade (~-20 dB) is 1/3 up the bar.
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
Assert.Equal(0.5f, AudioLevelMeter.ToDisplay(0.0316f), 2);
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f);
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.32f, 0.34f);
}
[Fact]
public void ToDisplay_ClampsAtFloor()
{
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.0001f));
Assert.Equal(0f, AudioLevelMeter.ToDisplay(-1f));
}
}
public class WaveToFloatTests
{
private static byte[] FloatSamples(params float[] values)
{
var bytes = new byte[values.Length * 4];
Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
return bytes;
}
[Fact]
public void IeeeFloat32_PreservesValues()
{
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
var samples = WaveToFloat.Convert(FloatSamples(0.25f, -0.5f, 1f), 12, format);
Assert.Equal(3, samples.Length);
Assert.Equal(0.25f, samples[0], 4);
Assert.Equal(-0.5f, samples[1], 4);
Assert.Equal(1f, samples[2], 4);
}
[Fact]
public void Pcm16_NormalizesToUnitRange()
{
var format = WaveFormat.CreateCustomFormat(WaveFormatEncoding.Pcm, 48000, 1, 48000 * 2, 2, 16);
var bytes = new byte[] { 0x00, 0x00, 0xFF, 0x7F, 0x00, 0x80 };
var samples = WaveToFloat.Convert(bytes, 6, format);
Assert.Equal(3, samples.Length);
Assert.Equal(0f, samples[0], 4);
Assert.Equal(1f, samples[1], 4);
Assert.Equal(-1f, samples[2], 4);
}
[Fact]
public void TruncatedTrailingBytes_AreIgnored()
{
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
var bytes = new byte[] { 0, 0, 0x80, 0x3F, 1, 2, 3 }; // 1 float + 3 stray bytes
var samples = WaveToFloat.Convert(bytes, bytes.Length, format);
Assert.Single(samples);
Assert.Equal(1f, samples[0], 4);
}
}
+87 -23
View File
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit; using Xunit;
using ytLive.Services; using ytLive.Services;
@@ -9,20 +12,28 @@ public class CameraManagerTests
{ {
private readonly List<string> _started; private readonly List<string> _started;
private readonly List<string> _stopped; private readonly List<string> _stopped;
private readonly VideoFrame? _pumpOnStart;
private readonly string? _failOnStart;
public string DeviceId { get; } public string DeviceId { get; }
public event Action<VideoFrame>? FrameAvailable; 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; DeviceId = deviceId;
_started = started; _started = started;
_stopped = stopped; _stopped = stopped;
_pumpOnStart = pumpOnStart;
_failOnStart = failOnStart;
} }
public Task StartAsync() public Task StartAsync()
{ {
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
_started.Add(DeviceId); _started.Add(DeviceId);
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -33,29 +44,15 @@ public class CameraManagerTests
} }
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame); public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
public void Fail(string message) => SourceFailed?.Invoke(message);
} }
private sealed class FailingFrameSource : ICameraFrameSource // 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);
public string DeviceId { get; } // the proof itself is exercised by the dedicated tests below.
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);
}
private static CameraManager CreateManager(List<string> started, List<string> stopped) 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 private sealed class FakeEnumerator : ICameraEnumerator
{ {
@@ -93,7 +90,8 @@ public class CameraManagerTests
FakeFrameSource? captured = null; FakeFrameSource? captured = null;
var manager = new CameraManager( var manager = new CameraManager(
new FakeEnumerator(), new FakeEnumerator(),
id => captured = new FakeFrameSource(id, started, stopped)); id => captured = new FakeFrameSource(id, started, stopped),
null, TimeSpan.Zero);
await manager.AcquireAsync("dev1"); await manager.AcquireAsync("dev1");
var frame = new VideoFrame(2, 2, new byte[16]); var frame = new VideoFrame(2, 2, new byte[16]);
@@ -105,7 +103,10 @@ public class CameraManagerTests
[Fact] [Fact]
public async Task Acquire_FailingCamera_ReturnsFalseAndStops() 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; string? failedDevice = null;
manager.CameraFailed += (device, _) => failedDevice = device; manager.CameraFailed += (device, _) => failedDevice = device;
@@ -119,4 +120,67 @@ public class CameraManagerTests
var manager = CreateManager(new List<string>(), new List<string>()); var manager = CreateManager(new List<string>(), new List<string>());
Assert.False(await manager.AcquireAsync(" ")); 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);
}
} }
+290
View File
@@ -0,0 +1,290 @@
using System.Diagnostics;
using System.Threading.Channels;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg subprocess encoder (TASK 4 ship step 3). The integration test drives
/// the full lifecycle against fakes: encoder probe → subprocess start with the right
/// args → raw frames into stdin → stderr progress parsed into health → graceful stop.
/// The units pin down the pure pieces (args, progress parser, encoder picker) and
/// the failure edges. No real ffmpeg binary, no network.
/// </summary>
public class FfmpegEncoderTests
{
private sealed class QueuedReader : TextReader
{
private readonly Channel<string?> _channel = Channel.CreateUnbounded<string?>();
public void Enqueue(string s) => _channel.Writer.TryWrite(s);
public void Complete() => _channel.Writer.TryComplete();
public override string? ReadLine() => ReadLineAsync().GetAwaiter().GetResult();
public override async Task<string?> ReadLineAsync()
{
try
{
return await _channel.Reader.ReadAsync().AsTask().ConfigureAwait(false);
}
catch (ChannelClosedException)
{
return null; // stream EOF — the process exited and the pipe closed
}
}
}
private sealed class FakeEncoderProcess : IEncoderProcess
{
public ProcessStartInfo? StartInfo { get; private set; }
public MemoryStream Stdin { get; } = new();
public Stream StandardInput => Stdin;
public TextReader StandardOutput { get; }
public bool HasExited { get; private set; }
public int ExitCode { get; private set; }
public bool Killed { get; private set; }
public bool Started { get; private set; }
private readonly TaskCompletionSource _exit =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly QueuedReader _error = new();
public FakeEncoderProcess(string probeOutput = "")
=> StandardOutput = new StringReader(probeOutput);
public TextReader StandardError => _error;
public void EnqueueStderr(string line) => _error.Enqueue(line);
public void Start(ProcessStartInfo startInfo)
{
StartInfo = startInfo;
Started = true;
}
public void SignalExit(int code = 0)
{
ExitCode = code;
HasExited = true;
_error.Complete();
_exit.TrySetResult();
}
public void Kill()
{
Killed = true;
_error.Complete();
_exit.TrySetResult();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exit.Task;
public void Dispose()
{
_error.Complete();
_exit.TrySetResult();
}
}
private sealed class FakeEncoderProcessFactory
{
private readonly Queue<FakeEncoderProcess> _processes = new();
public void Return(FakeEncoderProcess p) => _processes.Enqueue(p);
public FakeEncoderProcess Create() => _processes.Dequeue();
}
private sealed class StubLocator : IFfmpegLocator
{
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(@"C:\tools\ffmpeg.exe");
}
private static byte[] BgraFrame(int w, int h, byte b, byte g, byte r)
{
var bytes = new byte[w * h * 4];
for (var i = 0; i < bytes.Length; i += 4)
{
bytes[i] = b;
bytes[i + 1] = g;
bytes[i + 2] = r;
bytes[i + 3] = 255;
}
return bytes;
}
[Fact]
public async Task Start_ProbesEncoder_FeedsFrames_ParsesHealth_StopsGracefully()
{
const string encoders =
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
" V..... libopenh264 OpenH264 H.264 (codec h264)\n";
var probe = new FakeEncoderProcess(encoders);
var encoderProc = new FakeEncoderProcess();
var factory = new FakeEncoderProcessFactory();
factory.Return(probe);
factory.Return(encoderProc);
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
encoder.HealthUpdated += (_, h) => health.TrySetResult(h);
var options = new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc-xyz",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
};
await encoder.StartAsync(options);
Assert.True(probe.Started, "the -encoders probe must run");
Assert.True(encoderProc.Started, "the encoder subprocess must start");
Assert.Equal(@"C:\tools\ffmpeg.exe", encoderProc.StartInfo!.FileName);
var args = encoderProc.StartInfo.ArgumentList.ToArray();
var c = Array.IndexOf(args, "-c:v");
Assert.True(
args[c + 1] == "h264_nvenc",
"hardware NVENC must be preferred over the listed openh264");
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
encoderProc.EnqueueStderr(
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(4000.1, h.CurrentBitrate, 1);
Assert.Equal(59.9, h.FPS, 1);
Assert.Equal(TimeSpan.FromSeconds(2), h.StreamDuration);
encoderProc.SignalExit();
await encoder.StopAsync();
Assert.False(encoder.IsRunning);
Assert.False(encoderProc.Killed, "graceful stop must not kill the process");
Assert.Equal(StreamStatus.Offline, h.Status);
}
[Fact]
public async Task Start_WithoutRtmpUrl_Throws()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
}
[Fact]
public async Task SubmitFrame_WhenNotRunning_Throws()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await Assert.ThrowsAsync<InvalidOperationException>(
() => encoder.SubmitFrameAsync(new VideoFrame(2, 2, new byte[16])));
}
[Fact]
public async Task Stop_WithoutStart_IsNoop()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await encoder.StopAsync();
Assert.False(encoder.IsRunning);
}
[Fact]
public async Task ProcessDeath_RaisesFailed()
{
var encoderProc = new FakeEncoderProcess();
var factory = new FakeEncoderProcessFactory();
factory.Return(new FakeEncoderProcess());
factory.Return(encoderProc);
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
encoder.ProcessFailed += (_, msg) => failed.TrySetResult(msg);
await encoder.StartAsync(new EncoderOptions { RtmpUrl = "rtmp://x/y" });
encoderProc.SignalExit(1);
var msg = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("1", msg);
}
[Fact]
public void Args_Gop_IsFpsTimesFour()
{
var options = new EncoderOptions { Fps = 60 };
Assert.Equal(240, options.GopSize);
var args = FfmpegArgs.Build(options, "libopenh264").ToArray();
var g = Array.IndexOf(args, "-g");
Assert.Equal("240", args[g + 1]);
var keyint = Array.IndexOf(args, "-keyint_min");
Assert.Equal("240", args[keyint + 1]);
}
[Fact]
public void Args_IncludeInputOutputAndCompliance()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
},
"libopenh264").ToArray();
Assert.Contains("-f", args);
Assert.Contains("rawvideo", args);
Assert.Contains("pipe:0", args);
Assert.Contains("1920x1080", args);
Assert.Contains("anullsrc=channel_layout=stereo:sample_rate=48000", args);
Assert.Contains("-sc_threshold", args);
Assert.Contains("-bf", args);
Assert.Contains("yuv420p", args);
Assert.Contains("aac", args);
Assert.Contains("flv", args);
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
}
[Fact]
public void ProgressParser_ParsesRealStatsLine()
{
var p = FfmpegProgressParser.TryParse(
"frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x");
Assert.NotNull(p);
Assert.Equal(123, p.Value.Frame);
Assert.Equal(59.9, p.Value.Fps, 1);
Assert.Equal(4000.1, p.Value.BitrateKbps, 1);
Assert.Equal(2.04, p.Value.Duration.TotalSeconds, 2);
Assert.Equal(1024 * 1024, p.Value.SizeBytes);
}
[Fact]
public void ProgressParser_IgnoresBannerAndErrors()
{
Assert.Null(FfmpegProgressParser.TryParse("ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers"));
Assert.Null(FfmpegProgressParser.TryParse("Error while opening encoder for output stream #0:0"));
Assert.Null(FfmpegProgressParser.TryParse(""));
}
[Fact]
public void EncoderPicker_PrefersHardware_NeverLibx264()
{
var listing =
" V..... libx264 libx264 H.264 / AVC (codec h264)\n" +
" V..... libopenh264 OpenH264 H.264 (codec h264)\n" +
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n";
Assert.Equal("h264_nvenc", FfmpegEncoderPicker.Pick(listing));
var onlyGpl = " V..... libx264 libx264 H.264 / AVC (codec h264)\n";
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(onlyGpl));
var software = " V..... libopenh264 OpenH264 H.264 (codec h264)\n";
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(software));
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
}
}
+266
View File
@@ -0,0 +1,266 @@
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
/// the tier's FPS and paces frames into the encoder. The integration test drives
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
/// that stop tears the pump down cleanly. The units pin the failure edges: the
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
/// </summary>
public class FramePumpTests
{
private sealed class FakeEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> Frames = new();
public int StartCount;
public int StopCount;
public bool Disposed;
public EncoderOptions? LastOptions;
public Exception? StartError;
public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
StartCount++;
LastOptions = options;
if (StartError != null) throw StartError;
return Task.CompletedTask;
}
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
Frames.Add(frame);
FrameArrived.TrySetResult();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken = default)
{
StopCount++;
return Task.CompletedTask;
}
public void Dispose() => Disposed = true;
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
}
private static Scene BackdropScene()
{
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" });
return scene;
}
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
List<string>? log = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
sceneProvider: scene ?? (() => BackdropScene()),
frameResolver: resolve ?? (_ => null),
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: options ?? (() => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = 60,
}),
encoderFactory: () => encoder,
log: log != null ? m => log.Add(m) : null,
pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
socialBar: socialBar);
}
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
{
var i = (y * frame.Width + x) * 4;
var br = frame.BgraPixels[i + 2];
var bg = frame.BgraPixels[i + 1];
var bb = frame.BgraPixels[i];
Assert.True(
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
}
[Fact]
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackdrop: true } ? red : null);
await pump.StartAsync();
Assert.True(pump.IsRunning);
Assert.Equal(1, encoder.StartCount);
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
var frame = encoder.Frames[0];
Assert.Equal(64, frame.Width);
Assert.Equal(48, frame.Height);
AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in
await pump.StopAsync();
Assert.False(pump.IsRunning);
Assert.Equal(1, encoder.StopCount);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
{
var encoder = new FakeEncoder();
var log = new List<string>();
using var pump = NewPump(encoder, options: () => null, log: log);
await pump.StartAsync();
Assert.False(pump.IsRunning);
Assert.Equal(0, encoder.StartCount);
Assert.Contains(log, m => m.Contains("RTMP"));
await pump.StopAsync(); // no-op after a skipped start
Assert.Equal(0, encoder.StopCount);
}
[Fact]
public async Task Start_WhileRunning_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StartAsync();
await pump.StartAsync();
Assert.Equal(1, encoder.StartCount);
}
[Fact]
public async Task Stop_WithoutStart_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StopAsync();
Assert.Equal(0, encoder.StopCount);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip
var encoder = new FakeEncoder();
var position = SocialBarPosition.Top;
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackdrop: true } ? red : null,
socialBar: () => (bar, position));
await pump.StartAsync();
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
// top corner clears back to backdrop.
position = SocialBarPosition.Bottom;
VideoFrame? flipped = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && flipped == null)
{
for (var i = 1; i < encoder.Frames.Count; i++)
{
var f = encoder.Frames[i];
if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
{
flipped = f;
break;
}
}
if (flipped == null) await Task.Delay(10);
}
Assert.NotNull(flipped);
AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge
await pump.StopAsync();
}
[Fact]
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
{
var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.Failed += (_, m) => failed.TrySetResult(m);
await pump.StartAsync();
Assert.False(pump.IsRunning);
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("access denied", message);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task ProcessDeath_StopsPump_AndRaisesFailed()
{
var encoder = new FakeEncoder();
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.Failed += (_, m) => failed.TrySetResult(m);
await pump.StartAsync();
encoder.RaiseProcessFailed("FFmpeg exited with code 1");
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("1", message);
// The pump self-stops (fire-and-forget); poll the definitive teardown
// marker — the encoder's disposal — rather than the IsRunning flag, which
// StopAsync clears before the loop has fully drained.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!encoder.Disposed && DateTime.UtcNow < deadline)
await Task.Delay(10);
Assert.True(encoder.Disposed);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task HealthUpdated_ForwardsEncoderHealth()
{
var encoder = new FakeEncoder();
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.HealthUpdated += (_, h) => health.TrySetResult(h);
await pump.StartAsync();
encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(StreamStatus.Streaming, h.Status);
Assert.Equal(59.9, h.FPS, 1);
}
}
+76
View File
@@ -0,0 +1,76 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 4 game audio bar: the default detector's provider wiring — it composes
/// the full-screen monitor + loopback level into the hysteresis and raises
/// IsGameAudioActiveChanged on transitions. The transition math itself lives in
/// GameAudioHysteresisTests.
/// </summary>
public class GameAudioDetectorTests
{
[Fact]
public void Poll_RaisesChanged_WhenGameAppearsAndLeaves()
{
var now = new DateTime(2026, 8, 13, 12, 0, 0);
int? monitor = 0;
var level = 0f;
var detector = new GameAudioDetector(() => monitor, () => level, () => now);
var changes = new List<bool>();
detector.IsGameAudioActiveChanged += a => changes.Add(a);
level = 0.9f;
detector.Poll();
Assert.False(detector.IsGameAudioActive);
now = now.AddMilliseconds(600);
detector.Poll();
Assert.True(detector.IsGameAudioActive);
Assert.Equal(new[] { true }, changes);
level = 0f;
now = now.AddSeconds(2);
detector.Poll();
Assert.True(detector.IsGameAudioActive);
Assert.Equal(new[] { true }, changes);
monitor = null;
detector.Poll();
Assert.True(detector.IsGameAudioActive);
now = now.AddSeconds(2);
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(new[] { true, false }, changes);
}
[Fact]
public void Poll_StaysInactive_WhenNoFullScreenMonitor()
{
var detector = new GameAudioDetector(() => null, () => 0.9f);
var changes = 0;
detector.IsGameAudioActiveChanged += _ => changes++;
for (var i = 0; i < 5; i++)
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(0, changes);
}
[Fact]
public void Poll_StaysInactive_WhileLoopbackSilent()
{
var detector = new GameAudioDetector(() => 0, () => 0f);
var changes = 0;
detector.IsGameAudioActiveChanged += _ => changes++;
for (var i = 0; i < 5; i++)
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(0, changes);
}
}
+82
View File
@@ -0,0 +1,82 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 4 game audio bar: the pure show/hide state machine. Show = a full-screen
/// app holds sound for half a second; hide = the app leaves fullscreen for a
/// second. Silence never hides an active bar — only the game leaving the preview
/// does (per the creator's rule).
/// </summary>
public class GameAudioHysteresisTests
{
private static readonly DateTime T0 = new(2026, 8, 13, 12, 0, 0);
[Fact]
public void StaysInactive_WhileSilent()
{
var h = new GameAudioHysteresis();
for (var i = 0; i < 30; i++)
{
h.Update(true, false, T0.AddSeconds(i));
Assert.False(h.IsActive);
}
}
[Fact]
public void StaysInactive_UntilSoundHoldsHalfSecond()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
Assert.False(h.IsActive);
h.Update(true, true, T0.AddMilliseconds(400));
Assert.False(h.IsActive);
h.Update(true, true, T0.AddMilliseconds(600));
Assert.True(h.IsActive);
}
[Fact]
public void NeverHides_WhileGameStillFullScreen_EvenWhenSilent()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
h.Update(true, true, T0.AddSeconds(1));
Assert.True(h.IsActive);
for (var i = 2; i < 40; i++)
{
h.Update(true, false, T0.AddSeconds(i));
Assert.True(h.IsActive);
}
}
[Fact]
public void Hides_AfterAppLeavesFullScreen()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
h.Update(true, true, T0.AddSeconds(1));
Assert.True(h.IsActive);
h.Update(false, true, T0.AddSeconds(2));
Assert.True(h.IsActive);
h.Update(false, true, T0.AddSeconds(4));
Assert.False(h.IsActive);
}
[Fact]
public void StaysInactive_WhenNoFullScreen_EvenWithSound()
{
var h = new GameAudioHysteresis();
for (var i = 0; i < 30; i++)
{
h.Update(false, true, T0.AddSeconds(i));
Assert.False(h.IsActive);
}
}
}
+5 -5
View File
@@ -30,14 +30,14 @@ public class LayoutStorePersistenceTests
Width = 480, Width = 480,
Height = 270, Height = 270,
}); });
store.Save(new[] { scene }, webcam); store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load(); var reloaded = store.Load();
var config = Assert.Single(reloaded[0].Elements); var config = Assert.Single(reloaded[0].Elements);
Assert.IsType<WebcamSceneConfig>(config); Assert.IsType<WebcamSceneConfig>(config);
reloaded[0].Elements.RemoveAt(0); reloaded[0].Elements.RemoveAt(0);
store.Save(reloaded, store.Webcam); store.Save(reloaded, store.Webcam, null);
var afterDelete = store.Load(); var afterDelete = store.Load();
Assert.Empty(afterDelete[0].Elements); Assert.Empty(afterDelete[0].Elements);
@@ -74,7 +74,7 @@ public class LayoutStorePersistenceTests
}; };
var scene = new Scene { Name = "Starting" }; var scene = new Scene { Name = "Starting" };
scene.Elements.Add(backdrop); scene.Elements.Add(backdrop);
store.Save(new[] { scene }, null); store.Save(new[] { scene }, null, null);
var reloaded = store.Load(); var reloaded = store.Load();
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements)); var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
@@ -102,7 +102,7 @@ public class LayoutStorePersistenceTests
using var store = new LayoutStore(path); using var store = new LayoutStore(path);
var on = new Scene { Name = "Live", HasBackdrop = true }; var on = new Scene { Name = "Live", HasBackdrop = true };
var off = new Scene { Name = "Ending", HasBackdrop = false }; 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(); var reloaded = store.Load();
@@ -216,7 +216,7 @@ public class LayoutStorePersistenceTests
RectWidth = 480, RectWidth = 480,
RectHeight = 270, RectHeight = 270,
}); });
store.Save(new[] { scene }, webcam); store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load(); var reloaded = store.Load();
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements)); var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Threading;
using System.Windows.Threading;
using Xunit;
namespace ytLive.Tests;
/// <summary>
/// Tests that spin up the real WPF App need exactly one Application instance per
/// AppDomain (WPF enforces it) — so they share this serial collection and a
/// single App created on one dedicated STA thread. Marshal test bodies onto it
/// via <see cref="RealAppHost.Run"/>, never `new App()` per test.
/// </summary>
[CollectionDefinition("RealApp", DisableParallelization = true)]
public sealed class RealAppCollection : ICollectionFixture<RealAppHost>
{
}
/// <summary>Owns the one-and-only WPF App on a dedicated STA thread.</summary>
public sealed class RealAppHost : IDisposable
{
private readonly Thread _thread;
private readonly Dispatcher _dispatcher;
public RealAppHost()
{
Exception? boot = null;
Dispatcher? dispatcher = null;
_thread = new Thread(() =>
{
try
{
var app = new App();
app.InitializeComponent();
dispatcher = Dispatcher.CurrentDispatcher;
Dispatcher.Run();
}
catch (Exception ex)
{
boot = ex;
}
});
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
while (dispatcher == null && boot == null)
Thread.Sleep(5);
if (boot != null)
throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
_dispatcher = dispatcher!;
}
/// <summary>Runs <paramref name="action"/> on the App's STA thread and rethrows any failure.</summary>
public void Run(Action action)
{
Exception? failure = null;
_dispatcher.Invoke(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
});
if (failure != null)
throw failure;
}
public void Dispose()
{
try
{
_dispatcher.InvokeShutdown();
}
catch
{
// The App never ran its message loop — shutdown is best-effort.
}
_thread.Join(2000);
}
}
+9 -21
View File
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
/// (doesn't fall through and deselect the source) and whether the round clip /// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval. /// renders as a circle rather than an oval.
/// </summary> /// </summary>
[Collection("RealApp")]
public sealed class RoundClipInteractionTests public sealed class RoundClipInteractionTests
{ {
private readonly RealAppHost _app;
public RoundClipInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact] [Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle() public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{ {
Exception? failure = null; _app.Run(Run);
var thread = new Thread(() =>
{
try
{
Run();
}
catch (Exception ex)
{
failure = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure != null)
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
} }
private void Run() private void Run()
{ {
var app = new App();
app.InitializeComponent();
// Never let the real MainWindow read/write the user's actual layout DB — // Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources // Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead. // over the real ones. Point it at a throwaway temp DB instead.
+37
View File
@@ -180,6 +180,43 @@ public class SceneCompositorTests
// untouched corner stays pure red // untouched corner stays pure red
AssertColor(output, 0, 0, 255, 0, 0); AssertColor(output, 0, 0, 255, 0, 0);
} }
[Fact]
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
{
var red = Solid(1920, 1080, 255, 0, 0);
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var compositor = new SceneCompositor();
// master-sized overlay: opaque green at (0,0), 50%-white at the center
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
var g = 0;
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
var w = (540 * 1920 + 960) * 4;
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
// flash: opaque magenta at (0,0) — the bar must cover it
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
}
} }
public class StretchMathTests public class StretchMathTests
+22
View File
@@ -145,6 +145,28 @@ public class ScreenCaptureManagerTests
Assert.False(await manager.AcquireAsync(" ")); Assert.False(await manager.AcquireAsync(" "));
} }
[Fact]
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
{
FakeScreenSource? captured = null;
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key));
Assert.Null(manager.GetLatestFrame("monitor:0"));
Assert.True(await manager.AcquireAsync("monitor:0"));
Assert.Null(manager.GetLatestFrame("monitor:0")); // nothing pumped yet
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
captured!.Pump(first);
captured.Pump(second);
Assert.Same(second, manager.GetLatestFrame("monitor:0"));
await manager.ReleaseAsync("monitor:0");
Assert.Null(manager.GetLatestFrame("monitor:0"));
}
// The one integration test for this branch: one target creates one shared // The one integration test for this branch: one target creates one shared
// WriteableBitmap, published once, and back-to-back frames coalesce to the // WriteableBitmap, published once, and back-to-back frames coalesce to the
// latest (a single pending UI copy per session). // latest (a single pending UI copy per session).
+259
View File
@@ -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);
}
}
+241
View File
@@ -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);
}
}
+414
View File
@@ -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);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Duplicate source names get an incrementing suffix with no space (Image,
/// Image2, Image3…) and the next free number is derived from the names actually
/// in the scene — deleting a middle resource never collides with a survivor.
/// Drives the real VM through the Add Source command (TextOverlay needs no
/// dialog), the same real-App + temp-DB pattern as the round-clip test.
/// </summary>
[Collection("RealApp")]
public sealed class SourceNamingTests
{
private readonly RealAppHost _app;
public SourceNamingTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Duplicate_Sources_Get_Next_Free_Numbered_Name()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-srcname-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
var scene = vm.ActiveScene!;
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}
+1
View File
@@ -36,6 +36,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
<PackageReference Include="NAudio.Wasapi" Version="2.2.1"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
</ItemGroup> </ItemGroup>