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:
+69
-18
@@ -105,6 +105,12 @@
|
||||
|
||||
<!-- Single three-state action button -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
ToolTip="{Binding AccountDisplayName}"
|
||||
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
||||
@@ -128,9 +134,9 @@
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="180"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Scenes: the app has five canonical scenes. Work with less,
|
||||
@@ -244,7 +250,7 @@
|
||||
</Helpers:FocusPreservingListBox>
|
||||
|
||||
<!-- Sources (for active scene) -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||
Click="AddSourceButton_Click">
|
||||
@@ -739,15 +745,23 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ BOTTOM BAR: Stream Health ═══ -->
|
||||
<!-- ═══ BOTTOM BAR: Stream Health + Audio ═══ -->
|
||||
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Line 1: bitrate / fps · quality -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0">
|
||||
@@ -757,23 +771,12 @@
|
||||
|
||||
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0">
|
||||
Foreground="White">
|
||||
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0"/>
|
||||
|
||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White"/>
|
||||
|
||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ComboBox Style="{StaticResource YtComboBox}" MinWidth="150"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
@@ -798,6 +801,54 @@
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Line 2: dropped / duration (under bitrate / fps) + the MIC chip.
|
||||
Audio is KISS: desktop/game audio is automatic ("it just is" —
|
||||
WASAPI loopback at unity, zero UI). The creator's only audio
|
||||
control is the mic — meter, volume, mute. -->
|
||||
<Grid Grid.Row="1" Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0"/>
|
||||
|
||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White"/>
|
||||
|
||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Sound meter: plain filled bar, blank → green → yellow → red,
|
||||
with zone markers at the yellow (60%) and red (80%) starts. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<Border Width="360" Height="14" CornerRadius="7" Background="#1a1a2e" Margin="8,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
|
||||
Background="{Binding MeterBrush}"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="216,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="288,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Slider Width="130" Minimum="0" Maximum="1"
|
||||
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<Button Content="{Binding MicMuteText}" Style="{StaticResource YtButtonSecondary}"
|
||||
Padding="10,3" Margin="8,0,0,0" Command="{Binding ToggleMicMuteCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||
|
||||
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
- **Scenes list:** drag rows to reorder scenes
|
||||
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"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…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; window capture, compositing, encoding pending
|
||||
### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"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…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; **audio UX shipped (UI)**: the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with a centered sound meter (plain filled bar, blank → green → yellow → red, zone markers at 60%/80%) + a MIC chip (volume slider + mute) — the creator's only audio control, desktop/game audio is automatic (KISS rule); 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); window capture, compositing, encoding pending
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -96,6 +96,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**, a single bottom-bar MIC chip: sound meter (blank → green → yellow → red with zone markers at 60%/80%, `MeterFillWidth`/`MeterBrush` from `MainViewModel.AudioLevel`) + volume slider (`MicVolume`) + mute (`MicMuteText`). 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.
|
||||
- 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)
|
||||
|
||||
Reference in New Issue
Block a user