ON-AIR sign right of REC: gray offline, red public, green private

This commit is contained in:
2026-08-24 10:22:18 -07:00
parent b0bace7432
commit 7357836f9b
5 changed files with 76 additions and 11 deletions
+4 -3
View File
@@ -9,8 +9,9 @@
- TASK 26 — webcam (+) menu gates on the app-wide identity (`CanAddWebcam` = staged != null && `_webcam == null`; XAML binding + tooltip; test `WebcamMenuGateTests`).
- TASK 27 — YouTube Chat layer one-per-layout gate (`CanAddYouTubeChat`, AddSource refuses a second) + legacy label heal ("Chat" → "YouTube Chat" on LoadLayout; creator renames untouched; test `ChatLayerGateTests`). Creator's ask: "do the same thing with the YouTube chat layer" + make the Layers row match the (+) picklist.
- TASK 19/23 status flip — code survey confirmed Control Surface UX fully shipped (thumbnail strip, two-state left panel, right panel gone, transitions, edit gating); map corrected.
- **TASK 20 step 1 — global hotkeys** (`Services/GlobalHotkeys.cs`, `MainViewModel.HandleHotkey`, `MainWindow.OnSourceInitialized` wiring): F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop mute, F9 TRAX. `IHotkeyRegistrar` seam + `RegistrarOverride`. Test `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose`.
- Suite: 226 total, 225 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs shipped same-commit.
- **TASK 20 step 1 — global hotkeys** (`Services/GlobalHotkeys.cs`, `MainViewModel.HandleHotkey`, `MainWindow.OnSourceInitialized` wiring): F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop mute, F9 TRAX. `IHotkeyRegistrar` seam + `RegistrarOverride`. Test `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose`. Hotkeys validated by creator.
- **ON-AIR sign** (creator ask): new status light + "ON-AIR" right of REC in the top bar (`OnAirBrush`: gray offline / red public / green private); old left-of-REC hardcoded indicator removed. Test `OnAirSignTests.OnAirBrush_Follows_Offline_Public_And_Private`.
- Suite: 227 total, 226 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs shipped same-commit.
- Visual pass on TASK 26/27 validated by creator (2026-08-24).
## ⚠️ Landmines
@@ -25,7 +26,7 @@
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted.
## Next steps
1. Creator runtime check of hotkeys: launch app, F3 stages BRB offline, F9 toggles TRAX, F7 flips mic dot. Note: F-keys are machine-global while the app runs.
1. Creator visual pass: ON-AIR sign states (gray offline → red on public live → green on private live).
2. Continue TASK 20: config UI + schema persistence for bindings (then conflict detection).
3. Queue after TASK 20: TASK 21 media source, TASK 22 audio sync offset, TASK 17 web source, TASK 18 local recording.
4. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL.
+5 -7
View File
@@ -114,17 +114,15 @@
<!-- REC sign (center): dark offline, red live, darker red when private -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- ON-AIR indicator -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,16,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" Fill="#e94560" VerticalAlignment="Center"/>
<TextBlock Text="ON-AIR" FontSize="16" FontWeight="Bold"
Foreground="#e94560" VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
Foreground="{Binding RecTextBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<!-- ON-AIR sign: gray offline, red public, green private -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" FontSize="16" FontWeight="Bold"
Foreground="{Binding OnAirBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
+3
View File
@@ -468,6 +468,7 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
@@ -930,6 +931,7 @@ public class MainViewModel : ViewModelBase
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
public bool ShowStartStream => IsOffline && IsConnected;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
@@ -1159,6 +1161,7 @@ public class MainViewModel : ViewModelBase
{
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ C# / WPF (.NET 8) following MVVM:
- `ViewModelBase.SetProperty<T>()` for property change notifications
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, 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.
- **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. **ON-AIR sign (2026-08-24):** to the right of REC — an always-visible dot+label bound to `OnAirBrush` (notified from the `StreamStatus` and `StreamVisibility` setters): gray (#555555) offline, red (#e94560) streaming public, green (#22c55e) streaming private; it replaces the old left-of-REC hardcoded-red IsLive-only indicator. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.ComponentModel;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The ON-AIR sign (right of REC in the top bar): gray offline, red when
/// streaming public, green when streaming private. Driven through the real
/// MainViewModel so StreamStatus/StreamVisibility notification wiring is
/// exercised, not just the computed property.
/// </summary>
[Collection("RealApp")]
public sealed class OnAirSignTests
{
[Fact]
public void OnAirBrush_Follows_Offline_Public_And_Private()
{
_app.Run(Run);
}
private readonly RealAppHost _app;
public OnAirSignTests(RealAppHost app) => _app = app;
private void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-onair-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
Assert.Equal(StreamStatus.Offline, vm.StreamStatus);
Assert.Equal("#555555", vm.OnAirBrush);
var raised = 0;
vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MainViewModel.OnAirBrush)) raised++; };
vm.StreamVisibility = "Public";
Assert.Equal("#555555", vm.OnAirBrush);
vm.StreamStatus = StreamStatus.Streaming;
Assert.Equal("#e94560", vm.OnAirBrush);
vm.StreamVisibility = "Private";
Assert.Equal("#22c55e", vm.OnAirBrush);
Assert.Equal(3, raised);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}