Audio UX (UI) + bottom bar rework: two-line footer (dropped/duration under bitrate/fps), centered sound meter (blank→green→yellow→red, zone markers at 60%/80%) + MIC chip (MicVolume slider, ToggleMicMuteCommand) — the mic is the creator's only audio control, desktop/game audio is automatic (KISS rule); scenes list is content-height (no dead space before SOURCES); top bar shows the connected YT account avatar/name via SyncConnectedAccount; docs updated (65 tests passing)

This commit is contained in:
2026-08-07 15:20:48 -07:00
parent 4d10b23714
commit 4b3fb04322
5 changed files with 183 additions and 55 deletions
+76
View File
@@ -27,6 +27,11 @@ public class MainViewModel : ViewModelBase
private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
private string _accountAvatarUrl = string.Empty;
private string _accountDisplayName = string.Empty;
private double _audioLevel;
private double _micVolume = 1.0;
private bool _micMuted;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
@@ -187,6 +192,65 @@ public class MainViewModel : ViewModelBase
}
}
/// <summary>The connected YouTube account's avatar + name — shown in the top
/// bar so the creator always sees WHICH account is about to go live.</summary>
public string AccountAvatarUrl
{
get => _accountAvatarUrl;
private set => SetProperty(ref _accountAvatarUrl, value);
}
public string AccountDisplayName
{
get => _accountDisplayName;
private set => SetProperty(ref _accountDisplayName, value);
}
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
// ─── only audio control is the mic: meter + volume + mute.) ───
/// <summary>Current level 0..1 — fed by the audio mixer once capture lands.
/// Drives the sound meter's fill width + zone color.</summary>
public double AudioLevel
{
get => _audioLevel;
set
{
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
}
public double MeterFillWidth => AudioLevel * 360;
public string MeterBrush => AudioLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public double MicVolume
{
get => _micVolume;
set => SetProperty(ref _micVolume, Math.Clamp(value, 0, 1));
}
public bool MicMuted
{
get => _micMuted;
set
{
if (SetProperty(ref _micMuted, value))
OnPropertyChanged(nameof(MicMuteText));
}
}
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
@@ -533,6 +597,7 @@ public class MainViewModel : ViewModelBase
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -587,6 +652,7 @@ public class MainViewModel : ViewModelBase
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleMicMuteCommand = new RelayCommand(_ => MicMuted = !MicMuted);
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -651,6 +717,7 @@ public class MainViewModel : ViewModelBase
}
IsConnected = true;
SyncConnectedAccount();
}
catch (Exception ex)
{
@@ -658,6 +725,13 @@ public class MainViewModel : ViewModelBase
}
}
private void SyncConnectedAccount()
{
var channel = _youtubeAuth.CurrentChannel;
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
AccountDisplayName = channel?.DisplayName ?? string.Empty;
}
private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
@@ -1443,6 +1517,7 @@ public class MainViewModel : ViewModelBase
}
IsConnected = true;
SyncConnectedAccount();
return channel;
}
catch (Exception ex)
@@ -1494,6 +1569,7 @@ public class MainViewModel : ViewModelBase
_youtubeAuth.ClearSession();
TokenStore.Clear();
IsConnected = false;
SyncConnectedAccount();
AppLog.Write("Stream ended; session signed out");
}
+1 -1
View File
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`) |
| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Audio (KISS — the mic is the creator's only audio control; capture pipeline still pending):** `AudioLevel` (0..1) drives the bottom-bar sound meter (`MeterFillWidth`/`MeterBrush`, green→yellow→red with zone markers at 60%/80%), `MicVolume` slider + `MicMuteText`/`ToggleMicMuteCommand`; desktop/game audio is automatic (WASAPI loopback at unity, zero UI). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`) |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |