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
This commit is contained in:
+37
-28
@@ -7,30 +7,37 @@
|
|||||||
|
|
||||||
## Session state (last updated: 2026-08-13)
|
## Session state (last updated: 2026-08-13)
|
||||||
|
|
||||||
- **Branch:** `main`. The **audio UX follow-up** (`9f9ed34`) is **committed and pushed**.
|
- **Branch:** `main`. The audio UX follow-up (`9f9ed34`) and round 2 (`18abe42`)
|
||||||
- **This session (round 2 — the game audio bar + meter fixes):**
|
are **committed and pushed**.
|
||||||
1. **Meter scaling:** the bars looked dead on a flat scale — real speech/game RMS is
|
- **This session (round 3 — mic mute icon + REC sign):**
|
||||||
~0.01..0.1 linear, so the fill barely moved even at full volume. Added
|
1. **Mic mute icon:** the mic bar gained a **microphone glyph** (same 16px style
|
||||||
`AudioLevelMeter.ToDisplay(float)`: maps −60..0 dBFS linearly onto 0..1. Both meters
|
as the speaker, white → red + slash when muted, `ToolTip=MicMuteText`)
|
||||||
(mic `MeterLevel`, game `GameMeterLevel`) now scale the raw level through it before
|
**between the meter and the speaker** — both mutes adjacent with spacing
|
||||||
multiplying by volume. +2 unit tests.
|
between the icons; click reuses the same `MicSpeaker_MouseLeftButtonUp`
|
||||||
2. **Game bar placement correction (user: it belongs in the preview, not the footer):**
|
handler → `ToggleMicMuteCommand`. No VM/code-behind change.
|
||||||
moved the game audio bar OUT of the footer (footer back to two rows) and into the
|
2. **REC sign (top center):** the old LIVE badge (hidden when offline) became an
|
||||||
**preview window** as a bottom-center dark chip overlaid on the live display
|
**always-visible REC chip** — dark gray dot (#555) + dim "REC" (#888) offline,
|
||||||
(`MainWindow.xaml`, in the `PreviewGrid` next to the OpacityChip). Monitoring UI
|
**bright red (#e94560)** + "REC" + elapsed while live, **darker red (#8f1f1f)**
|
||||||
only — never rendered into the live output (the `SceneCompositor` is unaware of it).
|
when live with a **private** stream. Driven by new VM props
|
||||||
3. **About button (user: stop jacking Notepad; standalone app):** the top-bar About
|
`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate` (notified from the
|
||||||
button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer is GONE (button +
|
`StreamStatus` and `StreamVisibility` setters), privacy read off the **dialog's
|
||||||
`AboutButton_Click` removed). The **ytLlive wordmark (top-left) is now clickable**
|
chosen visibility** (`StreamVisibility == "Private"`) — the stream service still
|
||||||
and opens the existing in-app About overlay (`OpenAboutCommand`). Queued as task 21:
|
hardcodes `privacyStatus = "public"`, so the private-REC state only lights once
|
||||||
make a real logo + expand the About overlay into the creator's hub (premium / coffee
|
that's honored (TASK 4 ship step 7). The dot pulses while live; `LiveIndicatorVisible`
|
||||||
/ socials links + a scrolling licensing/legal panel, all in-app).
|
and `LivePulseOpacity` were removed.
|
||||||
- **Uncommitted:** this round-2 work — `Services/Audio/AudioLevelMeter.cs`,
|
3. **Voice filters queued as task 22** — bass boost, treble, noise suppression,
|
||||||
`ViewModels/MainViewModel.cs`, `MainWindow.xaml` (scaling + game bar + logo/About),
|
compressor on the mic input channel, KISS, always on (before the meter/encoder mix).
|
||||||
`MainWindow.xaml.cs` (AboutButton_Click + unused usings removed), `ytLive.Tests/AudioMixerTests.cs`
|
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
|
||||||
(2 new `ToDisplay` tests), memory docs (TASKS.md — incl. new task 21, ai.md,
|
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
|
||||||
Services/index.md, ViewModels/index.md, HANDOFF, THIRD-PARTY-NOTICES.txt).
|
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
|
||||||
- **Verified:** build 0 warnings, 0 errors; **169 tests passing** (full suite).
|
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.
|
||||||
|
Green must never be raised earlier (e.g. on loopback start or on `Start()` being called).
|
||||||
|
- **Uncommitted:** this round-3 work — `ViewModels/MainViewModel.cs` (REC props,
|
||||||
|
`_recDotPulse`, StreamVisibility/StreamStatus notifications, `LiveIndicatorVisible`/
|
||||||
|
`LivePulseOpacity` removed), `MainWindow.xaml` (REC chip + mic mute icon),
|
||||||
|
`TASKS.md` (task 22 + round-3 audio note), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
|
||||||
|
- **Verified:** pending (build + full test run right before commit).
|
||||||
- **Landmines:**
|
- **Landmines:**
|
||||||
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
||||||
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
||||||
@@ -41,6 +48,8 @@
|
|||||||
- `MicConnected` comes from the source `Started` event, raised right after
|
- `MicConnected` comes from the source `Started` event, raised right after
|
||||||
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
|
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
|
||||||
before asserting connection state.
|
before asserting connection state.
|
||||||
|
- The mic dot is red until a resource connects (see contract above) — green
|
||||||
|
only after `Started`, yellow on `Failed`.
|
||||||
- Zero mic devices at startup = red dot AND the mixer is never started, so
|
- 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.
|
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`;
|
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
|
||||||
@@ -55,10 +64,10 @@
|
|||||||
integration test (a real `MainWindow`), which never goes live — keep it that way.
|
integration test (a real `MainWindow`), which never goes live — keep it that way.
|
||||||
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
|
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
|
||||||
only, never the real instance.
|
only, never the real instance.
|
||||||
- **Next step:** commit + push the audio UX follow-up (the whole uncommitted set
|
- **Next step:** commit + push round 3 (one commit). Then TASK 4 ship step 6 — health
|
||||||
above in one commit). Then TASK 4 ship step 6 — health stats: bind
|
stats: bind `FramePump.HealthUpdated` (bitrate/FPS/duration) into the bottom bar.
|
||||||
`FramePump.HealthUpdated` (bitrate/FPS/duration) into the bottom bar. Nothing
|
Also queued: task 21 (logo + About hub), task 22 (voice filters). Nothing else
|
||||||
else queued — do not expand the task queue on your own. Optional, not queued:
|
queued — do not expand the task queue on your own. Optional, not queued:
|
||||||
rewriting the healed entry's `ProfileUrl` to
|
rewriting the healed entry's `ProfileUrl` to
|
||||||
`https://mastodon.llamachile.tube/@gramps` (user must say the word).
|
`https://mastodon.llamachile.tube/@gramps` (user must say the word).
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -98,16 +98,16 @@
|
|||||||
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
|
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
|
||||||
</Button>
|
</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 -->
|
||||||
@@ -931,6 +931,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">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
||||||
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
||||||
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **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
|
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
|
||||||
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||||
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
||||||
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||||
@@ -116,6 +116,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
|
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
|
||||||
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
|
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
|
||||||
21. ☐ **Logo + richer in-app About (2026-08-13, queued)** — the ytLlive wordmark in the top bar is clickable and opens the About overlay; make a real **logo** (CC0 per the icon guardrail), and expand the About overlay into the creator's hub: **links to premium unlock + coffee/thanks buys + socials** and a second (or third) **scrolling panel with the full licensing/legal texts on demand** — everything stays IN-APP (no Notepad, no browser tab; the app is standalone). The top-bar About button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer was removed on 2026-08-13 for exactly this reason
|
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)
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -61,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;
|
||||||
@@ -252,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));
|
||||||
@@ -591,7 +594,10 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
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;
|
||||||
@@ -672,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
|
||||||
@@ -705,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
|
||||||
{
|
{
|
||||||
@@ -2117,7 +2125,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();
|
||||||
}
|
}
|
||||||
@@ -2128,7 +2137,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2161,7 +2171,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||||
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
|
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||||
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Social bar ───
|
// ─── Social bar ───
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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, 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). 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user