c01206fb8a
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz (35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6 speed. Replaced polling with frame-driven capture of the composition controller's root visual — the mechanism WebView2CompositionControl and Flutter's webview_windows use (graphics_context.cc captures the root surface_ visual via CreateGraphicsCaptureItemFromVisual; reference: github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal webview_windows). Frames now arrive at the renderer's own pace; capture memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap. New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free- threaded Direct3D11CaptureFramePool + session (Straight alpha readback, per-frame FindContentBounds → CropBounds). WebView2Manager reworked around per-session composition controllers + one UI-thread Compositor created via the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041 projection lacks CreateOnCurrentThread); internal seam ctor (Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests. CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent HWND must exist for the composition controller); the hidden WebViewHostPanel overlay deleted. TransparentBackgroundScript unchanged. Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds) drives the seam with a FakeWebSource + background-STA DispatcherPump. Suite 293/293, 0 warnings. NOTE: composition path NOT yet verified on a device — the take is the next step. Web work committed locally only (no push per standing rule). Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF, MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17, Controls/ViewModels/Services indexes.
20 KiB
20 KiB
Services — index
External-facing logic: YouTube API, persistence. See
schema.md for the memory-map conventions.
| File | Purpose |
|---|---|
YouTubeAuthService.cs |
OAuth2 via Google: loopback callback (http://localhost:8765/oauth2/callback), token exchange, refresh, channel fetch. Constructor takes optional HttpClient + sessionChanged callback (test seam + save hook); session persists via Helpers/TokenStore (DPAPI); ClearSession() signs out (called by MainViewModel.StopStream on End Livestream) |
YouTubeStreamService.cs |
Broadcast/stream management via the v3 API. CreateBroadcast always sends privacyStatus = "private" (ship step 7 — private-only until v1) and the one-click v3 flags (enableAutoStart/Stop, enableMonitorStream=false, latencyPreference=low); injectable HttpClient? http = null ctor seam so tests can fake the API. GetOrCreateReusableStreamAsync (TASK 9, shipped 2026-08-16) returns the channel's ReusableStream — lists liveStreams?mine=true and reuses the existing cdn.isReusable stream, inserting once only on first use (resolution=variable, frameRate=variable, isReusable=true); a stream id given to CreateBroadcast binds at insert via contentDetails.boundStreamId. RTMP URL = ingestionAddress + "/" + streamName. GetStreamHealthAsync(streamId) (TASK 9 item 3, shipped 2026-08-16) polls liveStreams?part=status → StreamHealth with parsed configurationIssues[] |
StreamHealthReporter.cs |
Pure report-by-exception decision (TASK 9 item 3): BannerFor(issues) → HealthIssueReport(Text?, IsError) — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
YouTubeChatService.cs |
Polls liveChat/messages, raises MessageReceived; IDisposable |
LayoutStore.cs |
SQLite persistence (Microsoft.Data.Sqlite) at %APPDATA%\ytLlive\ytLlive.db; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema user_version 6 (Source.ClipShape/IsMirrored via ALTER TABLE for pre-v2 DBs; v3 = singleton Webcam + per-scene WebcamSceneConfig, migrated idempotently without backfill — the stale Source.DeviceId column remains but is no longer read/written; v4 = WebcamSceneConfig.RectWidth/RectHeight, the pre-Round rect for the round-to-rect restore; v5 = Source.IsBackdrop + Source.CaptureKey, the live-capture backdrop; v6 = Scene.HasBackdrop — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; MainViewModel.NormalizeBackgrounds re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned BackgroundUseDefault_*/BackgroundPath_* Settings keys purged at save). Settings key/value table holds app state: MicSourceName, ReusableStream (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
VideoFrame.cs |
Normalized CPU frame seam (Width/Height/tightly-packed BGRA byte[]) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
CameraDeviceInfo.cs |
(Id, DisplayName) for a physical capture device |
ICameraEnumerator.cs |
GetCamerasAsync() — seam so the picker/CameraManager never touch WinRT (tests inject fakes) |
ICameraFrameSource.cs |
StartAsync/StopAsync/FrameAvailable(VideoFrame) + SourceFailed(string) — seam for a running capture source (tests inject fakes) |
MediaCaptureCameraEnumerator.cs |
WinRT enumeration via DeviceInformation.FindAllAsync(DeviceClass.VideoCapture) |
MicrophoneDeviceInfo.cs |
(Id, DisplayName) for an audio capture (mic) device |
IMicrophoneEnumerator.cs |
GetMicrophonesAsync() — seam so the mic picker never touches WinRT (tests inject fakes) |
WinRtMicrophoneEnumerator.cs |
WinRT mic enumeration via DeviceInformation.FindAllAsync(DeviceClass.AudioCapture) (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
MediaCaptureFrameSource.cs |
CPU-first MediaCapture source (MemoryPreference = Cpu, BGRA8 via CreateFrameReaderAsync). Validated, not trusted: post-init checks (VideoDeviceId match, FrameSources non-empty, VideoDeviceController.GetAvailableMediaStreamProperties ≥ 1); reader.StartAsync() status read (throws on non-Success); capture.Failed + CameraStreamStateChanged subscribed → SourceFailed event. Fallback ladder: VideoPreview → VideoRecord retry. SharingMode.Exclusive + DeviceLost not in 19041 SDK projection; CameraStreamState.Failed compared by (int)2 (projection omits member names) |
CameraManager.cs |
Webcam capture ownership: refcounted by DeviceId, one shared WriteableBitmap per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); PreviewBitmapChanged/CameraFailed events. First-frame proof (4s timeout, configurable via ctor TimeSpan?): AcquireAsync returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces CameraFailed with suspect-app names (CameraConflictProbe) instead of a silent empty box. SourceFailed forwarded from the frame source as async death signal |
IFullScreenDetector.cs |
Seam for the win32 full-screen detector: int? GetForegroundFullScreenMonitorIndex(), int PrimaryMonitorIndex(), IReadOnlyList<DisplayInfo> GetDisplays() |
Win32FullScreenDetector.cs |
GetForegroundWindow + DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) + MonitorFromWindow + GetMonitorInfo; monitor covers all four edges → full-screen; own process excluded; monitor order = EnumDisplayMonitors order (static GetMonitorHandle(int) maps index→HMONITOR for the capture factory). GetDisplays() returns DisplayInfo (index/name/resolution/bounds/IsPrimary, friendly name via EnumDisplayDevices) for the in-app "Capture Display" picker; PrimaryMonitorIndex() is the auto-key fallback when no full-screen game is detected |
IScreenCaptureSource.cs |
Key + StartAsync/StopAsync/FrameAvailable(VideoFrame) — seam so ScreenCaptureManager never touches WinRT (tests inject fakes) |
Direct3D11Helper.cs |
The one shared IDirect3DDevice per process: P/Invoke d3d11.dll!D3D11CreateDevice (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI IDXGIDevice → the WinRT interop export CreateDirect3D11DeviceFromDXGIDevice → MarshalInterface<IDirect3DDevice>.FromAbi. CsWinRT does not project Windows.Graphics.Direct3D11.Direct3D11Helper, so this hand-rolls it. Do not revert to QI-for-IDirect3DDxgiInterfaceAccess — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200) |
CaptureInterop.cs |
ComImport bridges the WinRT-projection gap: IGraphicsCaptureItemInterop (CreateForMonitor/CreateForWindow), IInitializeWithWindow (GraphicsCapturePicker owner window so the OS picker shows) |
ScreenCaptureFrameSource.cs |
One Direct3D11CaptureFramePool (free-threaded, 2 buffers) + session per target; frames → SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, alpha ignored) → VideoFrame, bytes read via WindowsRuntimeMarshal.TryGetDataUnsafe (CsWinRT-safe — the IMemoryBufferByteAccess ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). CreateForMonitor/CreateForWindow/CreateForPicker |
ScreenCaptureManager.cs |
Screen-capture ownership mirroring CameraManager: refcounted by target key, one shared WriteableBitmap, dispatcher-coalesced latest-frame copies; PreviewBitmapChanged/CaptureFailed events; ReleaseAllAsync used on re-designation |
ScreenCaptureSourceFactory.cs |
Resolve(key) parses monitor:<n> / window:<hwnd> / picker:<name> into a source; PickAsync() shows the OS GraphicsCapturePicker and returns the picker: key (transient — a reload falls back to auto-detection) |
WebView2Manager.cs |
Web-source ownership (TASK 17, composition-capture since 2026-09-14): per registered Source { Type: WebSource } it owns a CoreWebView2CompositionController + its visual tree (RootVisualTarget child under a 1920×1080 root ContainerVisual on one Windows.UI.Composition.Compositor created on the UI thread via the CoreMessaging CreateDispatcherQueueController P/Invoke), a wrapped WebCaptureFrameSource, and the shared crop-sized WriteableBitmap preview (dispatcher-coalesced copy at Render priority, PreviewBitmapChanged raised only on recreation). Nav + TransparentBackgroundScript injection unchanged. Public ctor (Dispatcher); internal seam ctor (Dispatcher, Func<string, IScreenCaptureSource>?) for hermetic tests (no WinRT). Caller: MainViewModel.InitWebView2() (now invoked from MainWindow_Loaded — the parent HWND must exist). See ../ai.md "Slice 14" |
WebCaptureFrameSource.cs |
The IScreenCaptureSource for a web session: owns the GraphicsCaptureItem (CreateFromVisual) + a free-threaded Direct3D11CaptureFramePool (2 buffers) + session; readback via SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, BitmapAlphaMode.Straight — the compositor blends straight alpha); per-frame FindContentBounds alpha-bbox (internal static, unit-tested) stamps VideoFrame.CropBounds; ring up to 2 fresh frames + Epoch reuse; IsOpaque=false. Mirror of ScreenCaptureFrameSource |
Compositor/SceneCompositor.cs |
The output compositor (TASK 4 ship step 1): renders a scene into the encoder's master VideoFrame (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (UniformToFill cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional socialBarFrame + socialBarTop in master space, blitted last so the bar sits above the flash; the old BlitFlash generalized to BlitOverlay with source offsets). Pure and WPF-free: frames injected via a Func<SceneElement, VideoFrame?> resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by CompositorOptions (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see ai.md "Scene compositor" |
Compositor/CompositorOptions.cs |
The active tier's output rect (source space over the 1920×1080 master, integer-aligned — MainViewModel.OutputRectX can be 656.5) + target W×H |
Compositor/StretchMath.cs |
Pure pixel math: the WPF UniformToFill cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
Compositor/StaticPixelCache.cs |
Asset bytes → cached BGRA8 VideoFrame, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's BitmapImage) |
Encoder/IFfmpegLocator.cs |
TASK 4 ship step 2 seam: resolves an absolute path to ffmpeg.exe (PATH first → cached %APPDATA%\ytLlive\tools\ffmpeg.exe → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
SocialValidator.cs |
ISocialValidator seam + HttpSocialValidator default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse @user@domain additionally does a best-effort nodeinfo lookup (/.well-known/nodeinfo → software.name) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution probes well-known subdomains (mastodon., social., ... order in FediverseSubdomainCandidates) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; ResolveFediverseSoftwareAsync(domain, ct) on the seam powers the load-time heal. Constructor takes optional HttpClient + Action<string> log for tests |
Compositor/SocialBarRenderer.cs |
Social bar output strip (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 VideoFrame strip (1920 wide, 40px content + 24px glow pad) via RenderTargetBitmap — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green #2ecc71 glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the FramePump reads it from its own thread |
Encoder/EncoderOptions.cs |
One go-live's encoder config: full RtmpUrl (ingest + stream key), W×H/FPS/bitrate from the quality tier, VideoEncoder (null = probe), audio sample rate/channels, GopSize = FPS×4 (the ≤4s keyframe bound) |
Encoder/IFfmpegEncoder.cs |
TASK 4 ship step 3 seam: start/feed/stop the live encoder; HealthUpdated (parsed StreamHealth), ProcessFailed on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
Encoder/FfmpegEncoder.cs |
The default encoder: spawn ffmpeg.exe (probe -encoders first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse -stats lines into StreamHealth, graceful stop via stdin EOF + 10s kill watchdog. Constructed by MainViewModel since ship step 5 (new FfmpegEncoder(new FfmpegLocator())), driven by the FramePump |
Encoder/IEncoderProcess.cs |
Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches Process directly |
Encoder/FfmpegEncoderProcess.cs |
The real process wrapper (Process with all three std streams redirected) |
Encoder/FfmpegArgs.cs |
Pure FFmpeg command-line builder: rawvideo pipe:0 input, anullsrc silent audio (WASAPI replaces it), H.264+AAC, closed GOP (-g fps×4 -keyint_min -sc_threshold 0 -bf 0, yuv420p), FLV → RTMP |
Encoder/FfmpegProgressParser.cs |
Pure parser for frame=/fps=/size=/time=/bitrate= stats lines → FfmpegProgress |
Encoder/FfmpegEncoderPicker.cs |
Pure H.264 encoder picker from -encoders output: NVENC → QSV → AMF → OpenH264; never returns libx264 (GPL) |
Encoder/FramePump.cs |
TASK 4 ship step 5: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (Func<SceneElement, VideoFrame?> resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. StartAsync never throws (failures log + Failed); no RTMP URL = encoder skipped; StopAsync stops the encoder before awaiting the loop (backpressure deadlock); ProcessFailed self-stops. Since the bar bug-fix branch it takes an optional socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>? seam — a pre-rendered bar strip composited last (above the flash) at the top edge or SourceRectHeight − bar height. See ai.md "Live frame pipeline" |
Encoder/FfmpegLocator.cs |
The default locator: PATH probe wins, then the cache, then pull+extract (ffmpeg.exe + the libav*.dll family via a staging dir, failures via AppLog). Pinned to BtbN autobuild-2026-08-09-13-03 lgpl-shared (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see ai.md → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
INotificationService.cs |
Toast seam (TASK 24): Show(title, message, AppNotificationSeverity) + AppNotificationSeverity { Info, Success, Warning, Error } + Info/Success/Warning/Error extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
NotificationService.cs |
The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library NotificationManager; routes to the in-window NotificationArea named ToastArea (MainWindow bottom-right above the footer); lifetimes via the pure BuildRequest mapping + Cards tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (NeverExpires() = TimeSpan.MaxValue, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See ../ai.md "Toast notifications" |
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, with +10 dB input amplification so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
Audio/WaveToFloat.cs |
Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, WaveFormatExtensible IEEE-float subformat GUID, trailing partial samples ignored |
Audio/MasterLimiter.cs |
Master output limiter: a −1 dBFS ceiling (Ceiling = 0.891) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested |
Related: constructed in ViewModels/MainViewModel.cs
(no DI container yet). Models in Models/index.md.
OAuth credentials live in Helpers/OAuthCredentials.cs.