ON-AIR sign right of REC: gray offline, red public, green private
This commit is contained in:
+4
-3
@@ -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
@@ -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}}">
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user