Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1126a52b9 | |||
| fc33022006 | |||
| 46978b8bc3 | |||
| 58e7b3f3d1 | |||
| e2867198b9 | |||
| db068abbc7 | |||
| dcdfcb9cee | |||
| 9baae33916 | |||
| 5eecf4536f |
@@ -20,6 +20,13 @@ work lands directly on `main`, committed and pushed per work unit. No
|
||||
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
|
||||
not git mechanics.
|
||||
|
||||
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
|
||||
committing is expected and never needs extra permission. Pushing is different:
|
||||
push ONLY at sub-milestones and milestones. During a multi-commit refactor,
|
||||
that means commits land locally every partial; `git push` happens at the
|
||||
session's agreed checkpoint(s), not per-commit. When a milestone warrants a
|
||||
push the user will say so (or say "push it").
|
||||
|
||||
MUST read the architecture guide before planning or modifying the codebase:
|
||||
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
|
||||
|
||||
|
||||
+26
-8
@@ -6,11 +6,28 @@
|
||||
|
||||
No feature branches pre-1.0: all work lands on `main` per work unit.
|
||||
|
||||
**Latest commit:** `6031a56` — gear cleanup + Default Location for Recordings + single Start + socials hover glow.
|
||||
**Latest commit:** `e286719` — Commit 5/12 of the MainViewModel refactor (Webcam partial, tag `refactor-commit-5`).
|
||||
|
||||
## What's In Flight
|
||||
|
||||
Nothing — clean tree, ready for next task.
|
||||
**MainViewModel partial split (Phase 1):** 5 of 12 refactor commits landed and pushed.
|
||||
Tags `refactor-commit-0…5` = scaffold, Account, License, Scenes, Background, Webcam.
|
||||
Latest head `e286719`. Working tree clean.
|
||||
|
||||
Remaining partials in order: `Audio` (Commit 6), `Trax` (7), `Socials` (8), `Streaming` (9),
|
||||
`Chat` (10), `Overlays` (11). Each lands the same way: cut → build 0 warnings →
|
||||
`./scripts/verify.sh` + `./scripts/scope-check.sh "file1" …` → commit titled
|
||||
`refactor: split <Area> out of MainViewModel — Commit N/12 (tag refactor-commit-N)`
|
||||
with the `ViewModels/index.md` progress update in the SAME commit → `git tag refactor-commit-N`.
|
||||
**Do NOT push per commit (policy 2026-08-30):** commits land locally; `git push` only at
|
||||
a user-agreed sub-milestone/milestone ("push it"). Tags ride along on that push.
|
||||
|
||||
Known partial-usings list (grep-verified):
|
||||
`Scenes` = ObjectModel+Specialized+ComponentModel+`ytLive`+Models+Services;
|
||||
`Background` = ObjectModel+IO+Windows+Input+Media+Media.Imaging+Helpers+Models+Services;
|
||||
`Webcam` = Windows+Input+Media.Imaging+`ytLive`+Models+Services.
|
||||
`INotificationService.Info/Warning/Error/Success` are default interface methods → any
|
||||
partial calling `_notifications.X` needs `using ytLive.Services;`.
|
||||
|
||||
## Landmines
|
||||
|
||||
@@ -35,11 +52,12 @@ Nothing — clean tree, ready for next task.
|
||||
- TASK 22 (Audio sync offset): not started — required for v1.
|
||||
- TASK 30: gear cleanup + record location + single Start + preview layout restructure — shipped across commits `6031a56` + `61b850f` + `455a409`.
|
||||
- Monetization: deferred (creator rethinking price/subscription model).
|
||||
- **File refactor (in progress — Commit 0 done):** MainViewModel.cs (3,900 lines) will be split into functional partials (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License). `MainViewModel` is now `public partial class` (scaffold commit). Remaining: one commit per partial, each a roll-back point, `verify.sh` + `scope-check.sh` before each, docs in the same commit. MainWindow.xaml (1,681 lines) will be split into UserControls after Phase 1. Zero behavior change, tests stay green.
|
||||
- **File refactor (in progress — Commit 5/12 done):** MainViewModel.cs (3,900 lines) split into functional partials (Scenes, Background, Webcam ✔; Audio, Trax, Socials, Streaming, Chat, Overlays pending; Account, License ✔). One commit per partial, each a roll-back point (`refactor-commit-N` tags), `verify.sh` + `scope-check.sh` before each, docs in the same commit. MainWindow.xaml (1,681 lines) will be split into UserControls after Phase 1. Zero behavior change, tests stay green.
|
||||
- Monitoring: partials must re-declare their own `using`s — core file's usings do NOT carry across partial files. Field-initializer order across parts is compiler-ordered but no moved field has interdependent initializers.
|
||||
|
||||
## Session summary (2026-08-29)
|
||||
## Session summary (2026-08-30)
|
||||
|
||||
- Corrected task status tracking: TASK 4 → ✅ Done, TASK 3 item15 (Window capture) → ✅ absorbed into Screen picker, TASK 3 status updated to 27/30
|
||||
- ai.md updated: TASK 4 steps 5-7 added as shipped, stale "frame-pipeline wiring follows" removed
|
||||
- HANDOFF.md todo state expanded with full statuses for all active tasks
|
||||
- Discussed file organization: MainViewModel.cs (3,900 lines) is a god-object with 49 commands mixing scenes, camera, audio, streaming, socials, overlays, and auth. Next session: split into functional partials
|
||||
- Completed refactor Commits 3/12 (Scenes, `dcdfcb9`), 4/12 (Background, `db068ab`), 5/12 (Webcam, `e286719`) — each tagged `refactor-commit-N`, pushed with `--tags`, verified (0-warning clean build, only the 2 known test failures).
|
||||
- Background partial took the full bucket: background model, screen capture machinery, display picker, preview fns, capture commands. Webcam partial took identity + per-scene configs + capture callbacks + size safeguards.
|
||||
- Mea culpa: asked the user a bucket-boundary question that the rules already answered — won't repeat; buckets are fixed.
|
||||
- Test-suite note: full `vstest` run aborted early once (236/237) with only the 2 known failures — accepted by `verify.sh` as a known flake.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private bool _isConnected;
|
||||
private string _accountAvatarUrl = string.Empty;
|
||||
private string _accountDisplayName = string.Empty;
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get => _isConnected;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isConnected, value))
|
||||
{
|
||||
// ON-AIR requires a sign-in; dropping the session turns the pill
|
||||
// off so the state can never offer a stream without an account.
|
||||
if (!value) OnAirPillOn = false;
|
||||
OnPropertyChanged(nameof(CanToggleOnAir));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <summary>Tooltip for the account status light explaining the color.</summary>
|
||||
public string AccountStatusLightToolTip =>
|
||||
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
|
||||
: IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available."
|
||||
: "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally.";
|
||||
|
||||
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
|
||||
// Best-effort: refresh a near-expiry access token; a session that can no
|
||||
// longer refresh is discarded and the user signs in again.
|
||||
private async Task LoadSavedSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var saved = TokenStore.Load();
|
||||
if (saved == null) return;
|
||||
|
||||
_youtubeAuth.SetSession(saved);
|
||||
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||
{
|
||||
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
|
||||
if (!refreshed)
|
||||
{
|
||||
TokenStore.Clear();
|
||||
AppLog.Write("Saved session could not be refreshed; signed out");
|
||||
_notifications.Info("Signed out of YouTube",
|
||||
"The saved session expired — sign in again when you next go live.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
IsConnected = true;
|
||||
SyncConnectedAccount();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncConnectedAccount()
|
||||
{
|
||||
var channel = _youtubeAuth.CurrentChannel;
|
||||
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
|
||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||
}
|
||||
|
||||
// Runs the OAuth flow (browser + loopback callback) on a background thread.
|
||||
// The resulting channel is returned to the caller and persisted via the
|
||||
// auth service's sessionChanged hook.
|
||||
private async Task<YouTubeChannel?> SignInAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
|
||||
if (channel == null)
|
||||
{
|
||||
_notifications.Warning("Sign-in was unsuccessful", "Please try again.");
|
||||
return null;
|
||||
}
|
||||
|
||||
IsConnected = true;
|
||||
SyncConnectedAccount();
|
||||
return channel;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_notifications.Error("Sign-in failed", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
||||
private async Task SignOutYouTubeAsync()
|
||||
{
|
||||
_youtubeAuth.ClearSession();
|
||||
TokenStore.Clear();
|
||||
IsConnected = false;
|
||||
SyncConnectedAccount();
|
||||
NotifySocialsChanged();
|
||||
AppLog.Write("Socials: signed out of YouTube");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ChangeAccountAsync()
|
||||
{
|
||||
await SignOutYouTubeAsync();
|
||||
await SignInAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using NAudio.CoreAudioApi;
|
||||
using ytLive;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private readonly DispatcherTimer _volumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||
private double _audioLevel;
|
||||
private bool _volumeAdjusting;
|
||||
private bool _volumeFlash;
|
||||
private double _micVolume = 0.8;
|
||||
private bool _micMuted;
|
||||
private double? _volumeBeforeMute;
|
||||
private string? _micSourceName;
|
||||
private MicStatus _micStatus = MicStatus.NotConnected;
|
||||
private double _gameAudioLevel;
|
||||
private bool _gameVolumeAdjusting;
|
||||
private bool _gameVolumeFlash;
|
||||
private double _gameVolume = 1.0;
|
||||
private bool _gameMuted;
|
||||
private double? _gameVolumeBeforeMute;
|
||||
private AudioGainProvider? _gainProvider;
|
||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||
private readonly AudioMixer _audioMixer;
|
||||
|
||||
public ICommand ToggleMicMuteCommand { get; }
|
||||
public ICommand ToggleGameMuteCommand { get; }
|
||||
public ICommand OpenMicPickerCommand { get; }
|
||||
|
||||
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
|
||||
// ─── visible; the game bar (meter + volume + mute) appears only while a
|
||||
// ─── full-screen game is producing sound. Both meters preview live. ───
|
||||
|
||||
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
|
||||
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
|
||||
public double AudioLevel
|
||||
{
|
||||
get => _audioLevel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
|
||||
{
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Displayed meter level: 0 while muted; the volume position while
|
||||
/// the slider is being dragged (or briefly after an unmute, so the restored
|
||||
/// level flashes on the bar) so the creator sees where they're setting it;
|
||||
/// otherwise the realtime live level scaled by volume (raising the volume
|
||||
/// moves ambient noise up the bar). Read-only meter.</summary>
|
||||
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * 1.2 * MicVolume);
|
||||
|
||||
public double MeterFillWidth => MeterLevel * 288;
|
||||
|
||||
public string MeterBrush => MeterLevel switch
|
||||
{
|
||||
< 0.6 => "#22c55e",
|
||||
< 0.8 => "#eab308",
|
||||
_ => "#ef4444",
|
||||
};
|
||||
|
||||
/// <summary>Previewes the volume position while the slider is dragged; the
|
||||
/// bar returns to the live level on release (empty when there is no input).
|
||||
/// Starting a drag cancels any pending unmute flash.</summary>
|
||||
public void SetVolumeAdjusting(bool adjusting)
|
||||
{
|
||||
if (adjusting)
|
||||
CancelVolumeFlash();
|
||||
if (SetProperty(ref _volumeAdjusting, adjusting))
|
||||
{
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginVolumeFlash()
|
||||
{
|
||||
_volumeFlash = true;
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
_volumeFlashTimer.Stop();
|
||||
_volumeFlashTimer.Start();
|
||||
}
|
||||
|
||||
private void EndVolumeFlash()
|
||||
{
|
||||
_volumeFlashTimer.Stop();
|
||||
if (_volumeFlash)
|
||||
{
|
||||
_volumeFlash = false;
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelVolumeFlash()
|
||||
{
|
||||
_volumeFlashTimer.Stop();
|
||||
_volumeFlash = false;
|
||||
}
|
||||
|
||||
/// <summary>Mic gain (0..1). Running to 0 mutes (the speaker flips to muted);
|
||||
/// moving up from 0 unmutes (the mute indicator clears). The level being left
|
||||
/// is remembered so the speaker button can restore it.</summary>
|
||||
public double MicVolume
|
||||
{
|
||||
get => _micVolume;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (clamped == 0 && !_micMuted)
|
||||
_volumeBeforeMute ??= _micVolume;
|
||||
if (SetProperty(ref _micVolume, clamped))
|
||||
{
|
||||
var muted = clamped == 0;
|
||||
if (_micMuted != muted)
|
||||
{
|
||||
_micMuted = muted;
|
||||
OnPropertyChanged(nameof(MicMuted));
|
||||
OnPropertyChanged(nameof(MicMuteText));
|
||||
}
|
||||
if (!muted)
|
||||
_volumeBeforeMute = null;
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only: true whenever the volume is 0. Only MicVolume may
|
||||
/// change it, so the slider and the speaker can never disagree.</summary>
|
||||
public bool MicMuted => _micMuted;
|
||||
|
||||
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
|
||||
|
||||
/// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
|
||||
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
|
||||
public string? MicSourceName
|
||||
{
|
||||
get => _micSourceName;
|
||||
private set => SetProperty(ref _micSourceName, value);
|
||||
}
|
||||
|
||||
private void ToggleMicMute()
|
||||
{
|
||||
if (MicMuted)
|
||||
{
|
||||
MicVolume = _volumeBeforeMute ?? 0.8;
|
||||
BeginVolumeFlash();
|
||||
}
|
||||
else
|
||||
{
|
||||
_volumeBeforeMute = MicVolume;
|
||||
MicVolume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void PickMicrophone()
|
||||
{
|
||||
var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator))
|
||||
{
|
||||
Owner = System.Windows.Application.Current?.MainWindow
|
||||
};
|
||||
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
|
||||
{
|
||||
MicSourceName = dialog.PickedDevice.DisplayName;
|
||||
_layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
|
||||
_audioMixer.RestartMic(); // swap the live device immediately
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mic status dot (the MIC button) ───
|
||||
|
||||
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
|
||||
|
||||
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
|
||||
/// events and the startup device check (see StartMicCaptureAsync).</summary>
|
||||
public MicStatus MicStatus
|
||||
{
|
||||
get => _micStatus;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _micStatus, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(MicStatusBrush));
|
||||
OnPropertyChanged(nameof(MicStatusToolTip));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status dot: green = connected, yellow = problem with the requested
|
||||
/// connection, red = not connected (no device / not started).</summary>
|
||||
public SolidColorBrush MicStatusBrush => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => BarOnBrush,
|
||||
MicStatus.Problem => MicProblemBrush,
|
||||
_ => BarOffBrush,
|
||||
};
|
||||
|
||||
public string MicStatusToolTip => MicStatus switch
|
||||
{
|
||||
MicStatus.Connected => "Mic connected — click to change",
|
||||
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
|
||||
_ => "No mic connected — click to choose a microphone",
|
||||
};
|
||||
// ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar
|
||||
// ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ───
|
||||
private static void PushSystemVolume(double volume)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
device.AudioEndpointVolume.MasterVolumeLevelScalar = (float)volume;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
|
||||
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
|
||||
public double GameAudioLevel
|
||||
{
|
||||
get => _gameAudioLevel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Displayed game meter level: 0 while muted; the volume position
|
||||
/// while the slider is dragged (or briefly after an unmute flash); otherwise
|
||||
/// the realtime live level scaled by volume.</summary>
|
||||
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel));
|
||||
|
||||
public double GameMeterFillWidth => GameMeterLevel * 288;
|
||||
|
||||
public string GameMeterBrush => GameMeterLevel switch
|
||||
{
|
||||
< 0.6 => "#22c55e",
|
||||
< 0.8 => "#eab308",
|
||||
_ => "#ef4444",
|
||||
};
|
||||
|
||||
public void SetGameVolumeAdjusting(bool adjusting)
|
||||
{
|
||||
if (adjusting)
|
||||
CancelGameVolumeFlash();
|
||||
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
|
||||
{
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlash = true;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlashTimer.Start();
|
||||
}
|
||||
|
||||
private void EndGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
if (_gameVolumeFlash)
|
||||
{
|
||||
_gameVolumeFlash = false;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelGameVolumeFlash()
|
||||
{
|
||||
_gameVolumeFlashTimer.Stop();
|
||||
_gameVolumeFlash = false;
|
||||
}
|
||||
|
||||
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
|
||||
/// prior level is remembered so the speaker button can restore it.</summary>
|
||||
public double GameAudioVolume
|
||||
{
|
||||
get => _gameVolume;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (clamped == 0 && !_gameMuted)
|
||||
_gameVolumeBeforeMute ??= _gameVolume;
|
||||
if (SetProperty(ref _gameVolume, clamped))
|
||||
{
|
||||
var muted = clamped == 0;
|
||||
if (_gameMuted != muted)
|
||||
{
|
||||
_gameMuted = muted;
|
||||
OnPropertyChanged(nameof(GameMuted));
|
||||
OnPropertyChanged(nameof(GameMuteText));
|
||||
}
|
||||
if (!muted)
|
||||
_gameVolumeBeforeMute = null;
|
||||
OnPropertyChanged(nameof(GameMeterFillWidth));
|
||||
OnPropertyChanged(nameof(GameMeterBrush));
|
||||
UpdateTraxLocalGain();
|
||||
PushSystemVolume(clamped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
|
||||
/// speaker can never disagree).</summary>
|
||||
public bool GameMuted => _gameMuted;
|
||||
|
||||
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
|
||||
|
||||
private void ToggleGameMute()
|
||||
{
|
||||
if (GameMuted)
|
||||
{
|
||||
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
|
||||
BeginGameVolumeFlash();
|
||||
}
|
||||
else
|
||||
{
|
||||
_gameVolumeBeforeMute = GameAudioVolume;
|
||||
GameAudioVolume = 0;
|
||||
}
|
||||
}
|
||||
private void OnMicLevelChanged(float level)
|
||||
{
|
||||
// NAudio raises on its capture thread; marshal to the UI thread so the
|
||||
// meter binding updates safely.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => AudioLevel = level);
|
||||
else
|
||||
AudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnLoopbackLevelChanged(float level)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => GameAudioLevel = level);
|
||||
else
|
||||
GameAudioLevel = level;
|
||||
}
|
||||
|
||||
private void OnMicConnected()
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
|
||||
else
|
||||
MicStatus = MicStatus.Connected;
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
// The mixer logs the failure detail; here we only flip the dot to yellow.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
|
||||
else
|
||||
MicStatus = MicStatus.Problem;
|
||||
}
|
||||
|
||||
/// <summary>Starts capture once at startup: with no mic device present the
|
||||
/// dot stays red and capture never starts; otherwise the mixer starts and
|
||||
/// raises MicConnected (green) or MicFailed (yellow).</summary>
|
||||
private async Task StartMicCaptureAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
|
||||
if (mics.Count == 0)
|
||||
{
|
||||
MicStatus = MicStatus.NotConnected;
|
||||
AppLog.Write("Mic: no capture devices found — mic capture not started");
|
||||
_notifications.Warning("No microphone found",
|
||||
"Mic capture wasn't started — plug one in and pick it from the mixer.");
|
||||
return;
|
||||
}
|
||||
_audioMixer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Mic: device check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// Screen background: a permanent live capture (desktop/game) that every scene
|
||||
// shows at the bottom layer. One shared capture session per key — the
|
||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||
private readonly IFullScreenDetector _fullScreenDetector;
|
||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
||||
|
||||
private ImageSource? _activeBackgroundImage;
|
||||
private ImageSource? _backgroundImage;
|
||||
private bool _backgroundSizeMismatch;
|
||||
private string _backgroundSizeMismatchText = "";
|
||||
private HashSet<string> _liveCaptureKeys = new();
|
||||
|
||||
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
||||
|
||||
public ImageSource? ActiveBackgroundImage
|
||||
{
|
||||
get => _activeBackgroundImage;
|
||||
private set => SetProperty(ref _activeBackgroundImage, value);
|
||||
}
|
||||
|
||||
/// <summary>The active scene's live-capture background frame (rendered below the
|
||||
/// static background). Set from the shared capture bitmap as it arrives.</summary>
|
||||
public ImageSource? BackgroundImage
|
||||
{
|
||||
get => _backgroundImage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _backgroundImage, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
OnPropertyChanged(nameof(BackgroundVisible));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool BackgroundVisible => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.IsVisible == true;
|
||||
|
||||
public bool BackgroundSizeMismatch
|
||||
{
|
||||
get => _backgroundSizeMismatch;
|
||||
private set => SetProperty(ref _backgroundSizeMismatch, value);
|
||||
}
|
||||
|
||||
public string BackgroundSizeMismatchText
|
||||
{
|
||||
get => _backgroundSizeMismatchText;
|
||||
private set => SetProperty(ref _backgroundSizeMismatchText, value);
|
||||
}
|
||||
|
||||
public ICommand ChangeCaptureCommand { get; }
|
||||
public ICommand RefreshCaptureCommand { get; }
|
||||
public ICommand SetBackgroundDisplayCommand { get; }
|
||||
public ICommand ToggleShowDesktopCommand { get; }
|
||||
public ICommand BrowseBackgroundCommand { get; }
|
||||
|
||||
private void UpdateActiveBackground()
|
||||
{
|
||||
// Static-art rows only. Capture-flavored rows (Live) must NEVER feed
|
||||
// ActiveBackgroundImage — it renders above BackgroundImage, so it would
|
||||
// permanently cover the desktop/game capture. Live's canvas shows
|
||||
// DisplaySource instead (capture, falling back to live-background.jpg).
|
||||
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
|
||||
? ImageCache.Get(background.AssetId)
|
||||
: null;
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
}
|
||||
|
||||
private void UpdateBackgroundImage()
|
||||
{
|
||||
var scene = StagedScene;
|
||||
var background = scene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
BackgroundImage = background?.DisplaySource;
|
||||
|
||||
if (background != null && !background.UseDefaultBackground
|
||||
&& !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)
|
||||
&& System.IO.File.Exists(background.CustomBackgroundPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmi = new System.Windows.Media.Imaging.BitmapImage();
|
||||
bmi.BeginInit();
|
||||
bmi.UriSource = new System.Uri(background.CustomBackgroundPath);
|
||||
bmi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||||
bmi.EndInit();
|
||||
bmi.Freeze();
|
||||
|
||||
double expectedRatio = 1920.0 / 1080.0;
|
||||
double imageRatio = (double)bmi.PixelWidth / bmi.PixelHeight;
|
||||
double ratioDiff = Math.Abs(imageRatio - expectedRatio) / expectedRatio;
|
||||
|
||||
if (ratioDiff > 0.05)
|
||||
{
|
||||
BackgroundSizeMismatch = true;
|
||||
BackgroundSizeMismatchText =
|
||||
$"Image is {bmi.PixelWidth}\u00d7{bmi.PixelHeight} \u2014 stream expects 1920\u00d71080 (16:9)";
|
||||
}
|
||||
else
|
||||
{
|
||||
BackgroundSizeMismatch = false;
|
||||
BackgroundSizeMismatchText = "";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
BackgroundSizeMismatch = false;
|
||||
BackgroundSizeMismatchText = "";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
BackgroundSizeMismatch = false;
|
||||
BackgroundSizeMismatchText = "";
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshSnapshotsAsync()
|
||||
{
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
// Minis never render live captures. While the Live screen is staged
|
||||
// (being previewed) its mini shows the green placeholder; unstaged,
|
||||
// it shows the stored default art. Real-time surface = center only.
|
||||
var stagedLive = ReferenceEquals(scene, StagedScene)
|
||||
&& SceneCatalog.Is(scene.Name, SceneCatalog.Live);
|
||||
var img = stagedLive ? null : LoadBackgroundImage(scene);
|
||||
if (img != null)
|
||||
scene.Snapshot = img;
|
||||
else if (scene.Snapshot == null || stagedLive)
|
||||
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
|
||||
}
|
||||
}
|
||||
|
||||
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
|
||||
{
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg != null && !bg.UseDefaultBackground
|
||||
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
|
||||
&& System.IO.File.Exists(bg.CustomBackgroundPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.UriSource = new Uri(bg.CustomBackgroundPath);
|
||||
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
catch { /* fall through to AssetId */ }
|
||||
}
|
||||
if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId))
|
||||
return ImageCache.Get(bg.AssetId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BitmapSource CreatePlaceholderSnapshot(string sceneName)
|
||||
{
|
||||
var colors = new Dictionary<string, System.Windows.Media.Brush>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)),
|
||||
["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)),
|
||||
["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)),
|
||||
["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)),
|
||||
["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)),
|
||||
};
|
||||
var brush = colors.TryGetValue(sceneName, out var c) ? c
|
||||
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80));
|
||||
var dv = new System.Windows.Media.DrawingVisual();
|
||||
using (var ctx = dv.RenderOpen())
|
||||
{
|
||||
ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180));
|
||||
var ft = new System.Windows.Media.FormattedText(
|
||||
sceneName,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Windows.FlowDirection.LeftToRight,
|
||||
new System.Windows.Media.Typeface("Segoe UI"),
|
||||
18,
|
||||
System.Windows.Media.Brushes.White,
|
||||
96);
|
||||
ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2));
|
||||
}
|
||||
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
|
||||
320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
|
||||
rtb.Render(dv);
|
||||
rtb.Freeze();
|
||||
return rtb;
|
||||
}
|
||||
|
||||
// ─── Screen background capture (live desktop/game) ───
|
||||
|
||||
private const string MonitorKeyPrefix = "monitor:";
|
||||
|
||||
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
|
||||
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
|
||||
internal static Source? EnsureBackground(Scene scene)
|
||||
{
|
||||
if (!scene.HasBackground) return null;
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (background != null) return background;
|
||||
|
||||
background = CreateBackground(scene.Name);
|
||||
scene.Elements.Insert(0, background);
|
||||
return background;
|
||||
}
|
||||
|
||||
/// <summary>The one Background row for a scene. Live gets the capture flavor
|
||||
/// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
|
||||
/// live-background.jpg; every other screen is a static default-art layer.
|
||||
/// Both are named "Background" — the user never sees capture jargon here.</summary>
|
||||
internal static Source CreateBackground(string sceneName)
|
||||
{
|
||||
var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
|
||||
return new Source
|
||||
{
|
||||
Name = "Background",
|
||||
Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
|
||||
IsBackground = true,
|
||||
IsEnabled = true,
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = MasterFrameWidth,
|
||||
Height = MasterFrameHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Heals any loaded layout to the locked Background model: exactly
|
||||
/// one Background per canonical scene, correct flavor for the scene (static
|
||||
/// art everywhere, DisplayCapture on Live only), at index 0, named
|
||||
/// "Background". Duplicates and stale rows are dropped; missing ones seeded;
|
||||
/// non-canonical scenes get HasBackground=false and lose any backgrounds.</summary>
|
||||
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
|
||||
{
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
if (!SceneCatalog.HasBackground(scene.Name))
|
||||
{
|
||||
scene.HasBackground = false;
|
||||
foreach (var stale in scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList())
|
||||
scene.Elements.Remove(stale);
|
||||
continue;
|
||||
}
|
||||
|
||||
scene.HasBackground = true;
|
||||
var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
|
||||
var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
|
||||
|
||||
var candidates = scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList();
|
||||
var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
|
||||
|
||||
foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
|
||||
scene.Elements.Remove(extra);
|
||||
|
||||
if (keep == null)
|
||||
{
|
||||
keep = CreateBackground(scene.Name);
|
||||
scene.Elements.Insert(0, keep);
|
||||
}
|
||||
|
||||
// Source.Type's setter derives IsBackground from the enum, so a
|
||||
// flavor correction must re-assert the flag afterwards.
|
||||
if (keep.Type != wantedType)
|
||||
{
|
||||
keep.Type = wantedType;
|
||||
keep.IsBackground = true;
|
||||
}
|
||||
|
||||
keep.Name = "Background";
|
||||
keep.IsEnabled = true;
|
||||
|
||||
var index = scene.Elements.IndexOf(keep);
|
||||
if (index > 0)
|
||||
scene.Elements.Move(index, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the Live screen's background is a real-time surface. Every other
|
||||
// lookup below (auto-capture, focus hooks, refresh) must ignore the static
|
||||
// default-art rows so they can never start a desktop capture session.
|
||||
private IEnumerable<Source> AllBackgrounds()
|
||||
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
|
||||
.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
|
||||
|
||||
// Full-screen game on its monitor, else null (static bg fallback — no
|
||||
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
|
||||
// the window steals focus) and on focus regain (RefreshBackgroundAutoCapture).
|
||||
// When a background has ShowDesktop=true, returns the primary monitor instead
|
||||
// of null so the desktop is always visible.
|
||||
private string? ResolveAutoCaptureKey()
|
||||
{
|
||||
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
||||
if (gameMonitor != null)
|
||||
return $"{MonitorKeyPrefix}{gameMonitor}";
|
||||
|
||||
if (AllBackgrounds().Any(b => b.ShowDesktop))
|
||||
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
||||
return null;
|
||||
}
|
||||
|
||||
// After a layout load / file open: make sure every background-enabled scene
|
||||
// has a background, give any background without a persisted key the auto-detected
|
||||
// one, then acquire one capture per unique background key. ScreenCaptureManager
|
||||
// refcounts by key, so every scene pointing at the same monitor shares one
|
||||
// session. Captures no longer referenced by any scene are released.
|
||||
private void ReacquireScreenCaptures()
|
||||
{
|
||||
HealBackgrounds();
|
||||
|
||||
var auto = ResolveAutoCaptureKey();
|
||||
if (auto == null)
|
||||
{
|
||||
foreach (var background in AllBackgrounds())
|
||||
background.CaptureKey = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var background in AllBackgrounds())
|
||||
if (string.IsNullOrWhiteSpace(background.CaptureKey))
|
||||
background.CaptureKey = auto;
|
||||
}
|
||||
|
||||
var keys = AllBackgrounds()
|
||||
.Select(b => b.CaptureKey!)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.ToHashSet();
|
||||
|
||||
// A layout reload replaces the scenes; stop captures that are no longer
|
||||
// referenced (mirrors ReacquireWebcam's device swap).
|
||||
foreach (var stale in _liveCaptureKeys.Except(keys))
|
||||
_ = _screenCaptureManager.ReleaseAllAsync(stale);
|
||||
_liveCaptureKeys = keys;
|
||||
|
||||
foreach (var key in keys)
|
||||
_ = _screenCaptureManager.AcquireAsync(key);
|
||||
|
||||
_fullScreenDetector.StartWatching();
|
||||
}
|
||||
|
||||
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
|
||||
// every background pointing at that key picks it up.
|
||||
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
|
||||
{
|
||||
var activeBackground = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (activeBackground?.CaptureKey == key)
|
||||
BackgroundImage = bitmap;
|
||||
|
||||
foreach (var background in AllBackgrounds().Where(b => b.CaptureKey == key))
|
||||
background.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
|
||||
// When a full-screen game appears on a different monitor than the current
|
||||
// background, auto-redesignate. When ShowDesktop is enabled and no fullscreen
|
||||
// game is detected, fall back to the primary monitor (desktop view).
|
||||
private void OnFullscreenMonitorChanged(int? monitor)
|
||||
{
|
||||
if (monitor != null)
|
||||
{
|
||||
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
||||
var current = AllBackgrounds().FirstOrDefault();
|
||||
if (current?.CaptureKey == newKey) return;
|
||||
_ = RedesignateBackgroundAsync(newKey);
|
||||
}
|
||||
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
||||
{
|
||||
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
||||
var current = AllBackgrounds().FirstOrDefault();
|
||||
if (current?.CaptureKey == desktopKey) return;
|
||||
_ = RedesignateBackgroundAsync(desktopKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual refresh: the "Refresh Capture" context menu item. Reads the
|
||||
// current foreground directly (no caching needed — the hook handles
|
||||
// automatic detection).
|
||||
public void RefreshBackgroundAutoCapture()
|
||||
{
|
||||
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
||||
if (detected == null) return;
|
||||
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{detected}");
|
||||
}
|
||||
|
||||
/// <summary>Whether the active background is in Show Desktop mode.
|
||||
/// Bindable IsChecked property for the context menu toggle.</summary>
|
||||
public bool BackgroundShowDesktop
|
||||
{
|
||||
get => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false;
|
||||
set
|
||||
{
|
||||
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (background == null || background.ShowDesktop == value) return;
|
||||
background.ShowDesktop = value;
|
||||
OnPropertyChanged(nameof(BackgroundShowDesktop));
|
||||
ScheduleSave();
|
||||
if (value)
|
||||
{
|
||||
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
||||
if (gameMonitor != null)
|
||||
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{gameMonitor}");
|
||||
else
|
||||
_ = ClearBackgroundCaptureAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop;
|
||||
|
||||
private void BrowseBackground()
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null) return;
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg == null) return;
|
||||
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Select background image",
|
||||
Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|All files (*.*)|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
bg.CustomBackgroundPath = dlg.FileName;
|
||||
bg.UseDefaultBackground = false;
|
||||
try
|
||||
{
|
||||
using var fs = new System.IO.FileStream(dlg.FileName, System.IO.FileMode.Open, System.IO.FileAccess.Read);
|
||||
var bytes = new byte[fs.Length];
|
||||
fs.Read(bytes, 0, bytes.Length);
|
||||
var assetId = AddAsset(bytes);
|
||||
if (assetId != null)
|
||||
bg.AssetId = assetId;
|
||||
}
|
||||
catch { /* non-critical */ }
|
||||
|
||||
UpdateActiveBackground();
|
||||
RefreshSnapshotsAsync();
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
/// <summary>Release the background capture so the static placeholder image shows.</summary>
|
||||
private async Task ClearBackgroundCaptureAsync()
|
||||
{
|
||||
var oldKeys = AllBackgrounds()
|
||||
.Select(b => b.CaptureKey!)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var background in AllBackgrounds())
|
||||
{
|
||||
background.CaptureKey = null;
|
||||
background.VideoImageSource = null;
|
||||
}
|
||||
|
||||
foreach (var key in oldKeys)
|
||||
await _screenCaptureManager.ReleaseAllAsync(key);
|
||||
|
||||
_liveCaptureKeys.Clear();
|
||||
UpdateBackgroundImage();
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private async Task RedesignateBackgroundAsync(string newKey)
|
||||
{
|
||||
var oldKeys = AllBackgrounds()
|
||||
.Select(b => b.CaptureKey!)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var background in AllBackgrounds())
|
||||
background.CaptureKey = newKey;
|
||||
|
||||
foreach (var oldKey in oldKeys.Where(k => k != newKey))
|
||||
await _screenCaptureManager.ReleaseAllAsync(oldKey);
|
||||
if (!oldKeys.Contains(newKey))
|
||||
await _screenCaptureManager.AcquireAsync(newKey);
|
||||
_liveCaptureKeys = new HashSet<string> { newKey };
|
||||
UpdateBackgroundImage();
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
|
||||
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
|
||||
// back to auto-detection.
|
||||
public async Task ChangeBackgroundCaptureAsync()
|
||||
{
|
||||
var key = await _screenCaptureFactory.PickAsync();
|
||||
if (key == null) return;
|
||||
await RedesignateBackgroundAsync(key);
|
||||
}
|
||||
|
||||
// In-app display picker: point every background at a specific monitor.
|
||||
private void SetBackgroundCapture(DisplayInfo? display)
|
||||
{
|
||||
if (display == null) return;
|
||||
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}");
|
||||
}
|
||||
|
||||
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
|
||||
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
|
||||
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
|
||||
// fullscreen-game detect) falling back to live-background.jpg. ───
|
||||
|
||||
private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch
|
||||
{
|
||||
SceneCatalog.Starting => "starting-background.jpg",
|
||||
SceneCatalog.Brb => "brb-background.jpg",
|
||||
SceneCatalog.Chat => "chat-background.jpg",
|
||||
SceneCatalog.Ending => "ending-background.jpg",
|
||||
SceneCatalog.Live => "live-background.jpg",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Heals every scene to the locked Background model after a layout
|
||||
/// load: keeps the correct flavor per scene at index 0 named "Background",
|
||||
/// drops duplicates/stale rows, re-seeds missing ones.</summary>
|
||||
private void HealBackgrounds()
|
||||
{
|
||||
NormalizeBackgrounds(Scenes);
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (background != null)
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
}
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
}
|
||||
|
||||
/// <summary>Points a background row at its scene's shipped default art when it
|
||||
/// carries none. A custom image (Browse…) always wins and is never overwritten.</summary>
|
||||
private void StampDefaultBackgroundAsset(Source background, string sceneName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(background.AssetId)) return;
|
||||
if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return;
|
||||
var asset = DefaultBackgroundAsset(sceneName);
|
||||
if (asset == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute);
|
||||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||||
if (stream == null) return;
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
background.AssetId = AddAsset(ms.ToArray());
|
||||
}
|
||||
catch { /* non-critical — the app works without default art */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// TASK 10 — Polar license validation
|
||||
private string _licenseKey = string.Empty;
|
||||
private string _licenseMessage = string.Empty;
|
||||
private bool _isLicenseValidating;
|
||||
private bool _isLicenseEntryOpen;
|
||||
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
|
||||
|
||||
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
||||
/// false, the branding flash runs on every live stream.</summary>
|
||||
private bool _isPremium;
|
||||
public bool IsPremium
|
||||
{
|
||||
get => _isPremium;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isPremium, value)) return;
|
||||
// Flip the branding flash when premium state changes.
|
||||
BrandFlashEnabled = !value;
|
||||
}
|
||||
}
|
||||
|
||||
// TASK 10 — license key entry
|
||||
public string LicenseKey
|
||||
{
|
||||
get => _licenseKey;
|
||||
set => SetProperty(ref _licenseKey, value);
|
||||
}
|
||||
|
||||
public string LicenseMessage
|
||||
{
|
||||
get => _licenseMessage;
|
||||
private set => SetProperty(ref _licenseMessage, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseValidating
|
||||
{
|
||||
get => _isLicenseValidating;
|
||||
private set => SetProperty(ref _isLicenseValidating, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseEntryOpen
|
||||
{
|
||||
get => _isLicenseEntryOpen;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLicenseEntryOpen, value))
|
||||
OnPropertyChanged(nameof(IsHubVisible));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The About hub is visible only when neither the licenses panel
|
||||
/// nor the license entry panel is open.</summary>
|
||||
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
|
||||
|
||||
// ─── TASK 10 — Polar license validation ───
|
||||
|
||||
/// <summary>At startup, load the cached license state and re-validate online
|
||||
/// if a key exists. If offline and within the 14-day grace period, the cached
|
||||
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
|
||||
private async Task ValidateLicenseAtStartupAsync()
|
||||
{
|
||||
var savedKey = _layoutStore.LoadLicenseKey();
|
||||
if (string.IsNullOrWhiteSpace(savedKey))
|
||||
{
|
||||
IsPremium = false;
|
||||
return;
|
||||
}
|
||||
|
||||
LicenseKey = savedKey;
|
||||
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
|
||||
var cachedPremium = _layoutStore.LoadIsPremium();
|
||||
|
||||
// If the last validation was within the grace period, trust the cache
|
||||
// while we attempt an online re-validation in the background.
|
||||
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = cachedPremium;
|
||||
}
|
||||
|
||||
// Try an online check — if it succeeds, it overrides the cache.
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(savedKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Key revoked or expired — clear premium.
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
AppLog.Write($"License validation failed: {result.Error}");
|
||||
_notifications.Warning("Premium is no longer active",
|
||||
$"{result.Error} — watermark branding returns until the license is renewed.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Offline or network error — keep the cached state, log it.
|
||||
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
|
||||
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from the Activate button in the About overlay. Validates
|
||||
/// the entered key, persists it, and flips premium state.</summary>
|
||||
private async Task ActivateLicenseAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
|
||||
|
||||
IsLicenseValidating = true;
|
||||
LicenseMessage = "Validating\u2026";
|
||||
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(LicenseKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
LicenseMessage = "Premium activated! The watermark has been removed.";
|
||||
AppLog.Write("License activated successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveLicenseKey(null);
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
LicenseMessage = result.Error ?? "Invalid license key.";
|
||||
AppLog.Write($"License activation failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LicenseMessage = $"Activation error: {ex.Message}";
|
||||
AppLog.Write($"License activation error: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLicenseValidating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using ytLive;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private Scene? _stagedScene;
|
||||
private Scene? _liveScene;
|
||||
|
||||
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
||||
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
||||
|
||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||
|
||||
public Scene? StagedScene
|
||||
{
|
||||
get => _stagedScene;
|
||||
set
|
||||
{
|
||||
if (value != null && value.IsHidden) return;
|
||||
if (SetProperty(ref _stagedScene, value))
|
||||
{
|
||||
// Update IsStaged flags for thumbnail glow
|
||||
foreach (var s in Scenes)
|
||||
s.IsStaged = s == value;
|
||||
|
||||
SelectedElement = null;
|
||||
if (value != null)
|
||||
{
|
||||
var background = EnsureBackground(value);
|
||||
if (background != null)
|
||||
StampDefaultBackgroundAsset(background, value.Name);
|
||||
}
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
OnPropertyChanged(nameof(CanChangeBackground));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||
UpdateActiveBackground();
|
||||
UpdateBackgroundImage();
|
||||
RefreshSnapshotsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
|
||||
/// or on transition completion. FramePump reads this every tick.</summary>
|
||||
public Scene? LiveScene
|
||||
{
|
||||
get => _liveScene;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _liveScene, value))
|
||||
{
|
||||
foreach (var s in Scenes)
|
||||
s.IsLive = s == value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when offline — edit mode is available. When live/recording,
|
||||
/// all edit operations are locked out (gun safety).</summary>
|
||||
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
|
||||
|
||||
// ─── Transitions (TASK 19/23) ───
|
||||
|
||||
public TransitionType SelectedTransitionType
|
||||
{
|
||||
get => _selectedTransitionType;
|
||||
set => SetProperty(ref _selectedTransitionType, value);
|
||||
}
|
||||
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
||||
|
||||
public double TransitionDurationMs
|
||||
{
|
||||
get => _transitionDurationMs;
|
||||
set => SetProperty(ref _transitionDurationMs, value);
|
||||
}
|
||||
private double _transitionDurationMs = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
|
||||
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
|
||||
/// </summary>
|
||||
public void TransitionToScene(Scene scene)
|
||||
{
|
||||
if (scene.IsHidden || scene == StagedScene) return;
|
||||
StagedScene = scene;
|
||||
if (IsLive)
|
||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the current transition type to the next StagedScene change while live.
|
||||
/// Called from XAML when user picks a transition from the dropdown.
|
||||
/// </summary>
|
||||
public void ApplyTransitionToLive()
|
||||
{
|
||||
if (IsLive)
|
||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||
}
|
||||
|
||||
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
|
||||
|
||||
/// <summary>
|
||||
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
|
||||
/// after the user saves custom bindings from the config dialog.
|
||||
/// </summary>
|
||||
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
|
||||
|
||||
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
|
||||
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
|
||||
|
||||
public void HandleHotkey(Services.HotkeyId id)
|
||||
{
|
||||
switch (id)
|
||||
{
|
||||
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
|
||||
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
|
||||
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
|
||||
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
||||
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
||||
case Services.HotkeyId.StartStopStream:
|
||||
if (IsLive || IsRecording)
|
||||
{
|
||||
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
||||
}
|
||||
else if (StartStreamCommand.CanExecute(null))
|
||||
{
|
||||
StartStreamCommand.Execute(null);
|
||||
}
|
||||
break;
|
||||
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
|
||||
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
|
||||
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionToCanonical(string name)
|
||||
{
|
||||
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
|
||||
if (scene != null) TransitionToScene(scene);
|
||||
}
|
||||
|
||||
private void OpenHotkeyConfig()
|
||||
{
|
||||
var vm = new HotkeyConfigViewModel(_layoutStore);
|
||||
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
// Reload bindings from DB (the dialog already saved them).
|
||||
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
||||
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
|
||||
RefreshHotkeyLabels();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshHotkeyLabels()
|
||||
{
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
|
||||
&& _hotkeyBindings.TryGetValue(hid, out var chord))
|
||||
scene.HotkeyLabel = chord.ToDisplayString();
|
||||
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
|
||||
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
|
||||
scene.HotkeyLabel = defChord.ToDisplayString();
|
||||
else
|
||||
scene.HotkeyLabel = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
||||
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
|
||||
|
||||
public bool IsCountdownAvailable => StagedScene != null
|
||||
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
|
||||
|
||||
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
|
||||
/// "+" button may re-add. Work with less, never more.</summary>
|
||||
public IEnumerable<string> MissingScenes
|
||||
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
|
||||
|
||||
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
|
||||
public bool ShowAddScene => MissingScenes.Any();
|
||||
|
||||
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
|
||||
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
|
||||
// the Live screen's Background. Static-art screens never expose them.
|
||||
public bool CanChangeBackground =>
|
||||
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
|
||||
|
||||
// ─── Auto-save wiring ───
|
||||
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
foreach (Scene scene in e.NewItems)
|
||||
WireScene(scene);
|
||||
if (e.OldItems != null)
|
||||
foreach (Scene scene in e.OldItems)
|
||||
UnwireScene(scene);
|
||||
OnPropertyChanged(nameof(MissingScenes));
|
||||
OnPropertyChanged(nameof(ShowAddScene));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private void WireScene(Scene scene)
|
||||
{
|
||||
scene.PropertyChanged += OnScenePropertyChanged;
|
||||
scene.Elements.CollectionChanged += OnElementsChanged;
|
||||
}
|
||||
|
||||
private void UnwireScene(Scene scene)
|
||||
{
|
||||
scene.PropertyChanged -= OnScenePropertyChanged;
|
||||
scene.Elements.CollectionChanged -= OnElementsChanged;
|
||||
}
|
||||
|
||||
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
// A rename can drop a canonical scene in/out of MissingScenes.
|
||||
OnPropertyChanged(nameof(MissingScenes));
|
||||
OnPropertyChanged(nameof(ShowAddScene));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
foreach (SceneElement element in e.NewItems)
|
||||
element.PropertyChanged += OnElementPropertyChanged;
|
||||
if (e.OldItems != null)
|
||||
foreach (SceneElement element in e.OldItems)
|
||||
element.PropertyChanged -= OnElementPropertyChanged;
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
// Adds a canonical scene by name — only when it's actually missing. The
|
||||
// background flag follows the policy (Live yes, everyone else no).
|
||||
private void AddScene(string name)
|
||||
{
|
||||
if (!SceneCatalog.IsCanonical(name)) return;
|
||||
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
|
||||
var scene = new Scene
|
||||
{
|
||||
Name = name.Trim(),
|
||||
IsChatScene = SceneCatalog.IsChat(name),
|
||||
HasBackground = SceneCatalog.HasBackground(name),
|
||||
};
|
||||
EnsureBackground(scene);
|
||||
Scenes.Add(scene);
|
||||
StagedScene = scene;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
|
||||
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
|
||||
// (webcam.{scene}.config). CameraManager still owns the single session.
|
||||
private readonly ICameraEnumerator _cameraEnumerator;
|
||||
private readonly CameraManager _cameraManager;
|
||||
private Webcam? _webcam;
|
||||
private string? _webcamError;
|
||||
|
||||
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
|
||||
/// one camera identity app-wide; re-adding always opens the picker.</summary>
|
||||
public bool CanAddWebcam => StagedScene != null && _webcam == null;
|
||||
|
||||
/// <summary>
|
||||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||||
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||
/// </summary>
|
||||
public bool CanShowWebcamInStagedScene
|
||||
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
|
||||
|
||||
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||
public bool CanChangeWebcam => _webcam != null;
|
||||
|
||||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||||
|
||||
/// <summary>
|
||||
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
|
||||
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
|
||||
/// silent empty box. Cleared the moment a real frame arrives.
|
||||
/// </summary>
|
||||
public string? WebcamError
|
||||
{
|
||||
get => _webcamError;
|
||||
private set => SetProperty(ref _webcamError, value);
|
||||
}
|
||||
|
||||
// Webcam size safeguard: no more than half the frame in any dimension
|
||||
// (960x540 over the 1920x1080 master), and no less than 10% of it
|
||||
// (192x108). Enforced at resize and on layout load. The Chat scene is
|
||||
// exempt from the per-dimension half — its webcam may take half the
|
||||
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
|
||||
// matched by canonical name (SceneCatalog.IsChat).
|
||||
public const double WebcamMaxWidth = 960;
|
||||
public const double WebcamMaxHeight = 540;
|
||||
public const double WebcamChatMaxWidth = 1358;
|
||||
public const double WebcamChatMaxHeight = 764;
|
||||
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
|
||||
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
|
||||
|
||||
public static double MaxWebcamWidthFor(string? sceneName)
|
||||
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
|
||||
|
||||
public static double MaxWebcamHeightFor(string? sceneName)
|
||||
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
|
||||
|
||||
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
|
||||
{
|
||||
var maxWidth = MaxWebcamWidthFor(sceneName);
|
||||
var maxHeight = MaxWebcamHeightFor(sceneName);
|
||||
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
|
||||
if (scale < 1)
|
||||
{
|
||||
config.Width = Math.Round(config.Width * scale);
|
||||
config.Height = Math.Round(config.Height * scale);
|
||||
return;
|
||||
}
|
||||
|
||||
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
|
||||
if (minScale > 1)
|
||||
{
|
||||
config.Width = Math.Round(config.Width * minScale);
|
||||
config.Height = Math.Round(config.Height * minScale);
|
||||
}
|
||||
}
|
||||
|
||||
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
|
||||
// a Traditional webcam that ended up square (Round resize then reload lost the
|
||||
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
|
||||
// a square bounding box is correct there — and an explicit pre-Round rect wins.
|
||||
internal static void HealLegacySquareRect(WebcamSceneConfig config)
|
||||
{
|
||||
if (config.ClipShape != ClipShape.Traditional) return;
|
||||
if (config.RectWidth != null || config.RectHeight != null) return;
|
||||
if (Math.Abs(config.Width - config.Height) >= 1) return;
|
||||
config.Width = Math.Round(config.Height * 16.0 / 9.0);
|
||||
}
|
||||
|
||||
public ICommand AddWebcamCommand { get; }
|
||||
public ICommand ChangeWebcamCommand { get; }
|
||||
public ICommand ShowWebcamCommand { get; }
|
||||
|
||||
// After a layout load / file open: re-point the webcam identity, stop the
|
||||
// previous device if it changed, and acquire the current one once per scene
|
||||
// that uses it (CameraManager refcounts by DeviceId — one camera session).
|
||||
private void ReacquireWebcam()
|
||||
{
|
||||
var previousDevice = _webcam?.DeviceId;
|
||||
_webcam = _layoutStore.Webcam;
|
||||
var newDevice = _webcam?.DeviceId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
|
||||
_ = _cameraManager.ReleaseAllAsync(previousDevice);
|
||||
|
||||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
|
||||
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
|
||||
if (previousDevice != newDevice)
|
||||
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||||
_ = _cameraManager.AcquireAsync(newDevice);
|
||||
|
||||
// A session already running (same device, or one that produced a first
|
||||
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
|
||||
// because PreviewBitmapChanged never re-fires for an existing bitmap.
|
||||
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
|
||||
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||||
config.VideoImageSource = running;
|
||||
}
|
||||
|
||||
// CameraManager creates the shared WriteableBitmap on the UI thread at the
|
||||
// device's frame size; every scene's webcam config picks it up from here. A
|
||||
// bitmap means a real frame arrived — the camera is provably alive.
|
||||
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
||||
{
|
||||
if (_webcam?.DeviceId != deviceId) return;
|
||||
WebcamError = null;
|
||||
foreach (var scene in Scenes)
|
||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||
config.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
// The camera failed to start or died asynchronously (in use, offline, locked,
|
||||
// no frames within the proof timeout) — surface it instead of a silent box.
|
||||
private void OnCameraFailed(string deviceId, string message)
|
||||
{
|
||||
if (_webcam?.DeviceId != deviceId) return;
|
||||
WebcamError = $"Webcam offline: {message}";
|
||||
}
|
||||
|
||||
// Adds the webcam to the active scene. The creator ALWAYS picks from the
|
||||
// cameras Windows has registered — never silently resurrects the previous
|
||||
// camera (which is what happened after deleting one scene's webcam while
|
||||
// another scene still used it). Picking a different camera than the current
|
||||
// app-wide one swaps it everywhere, so the single-identity model stays honest;
|
||||
// each scene's placement config is independent (webcam.{scene}.config).
|
||||
private async Task AddWebcamToStagedSceneAsync()
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null || scene.WebcamConfig != null) return;
|
||||
|
||||
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||||
{
|
||||
Owner = Application.Current.MainWindow
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||||
var device = dialog.PickedDevice;
|
||||
|
||||
if (_webcam == null)
|
||||
{
|
||||
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
||||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
}
|
||||
else if (_webcam.DeviceId != device.Id)
|
||||
{
|
||||
await SwapWebcamIdentityAsync(device);
|
||||
}
|
||||
|
||||
var config = new WebcamSceneConfig
|
||||
{
|
||||
WebcamId = _webcam.Id,
|
||||
Name = _webcam.Name,
|
||||
Width = 480,
|
||||
Height = 270,
|
||||
X = MasterFrameWidth - 480 - 32,
|
||||
Y = MasterFrameHeight - 270 - 32,
|
||||
};
|
||||
|
||||
scene.Elements.Add(config);
|
||||
SelectedElement = config;
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
UpdateActiveBackground();
|
||||
|
||||
// PreviewBitmapChanged fires only on the camera's first frame, so a config
|
||||
// added while the session is already running must pick up the shared bitmap
|
||||
// directly (it's written in place from then on).
|
||||
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
|
||||
config.VideoImageSource = running;
|
||||
|
||||
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
|
||||
if (!started)
|
||||
{
|
||||
_notifications.Warning("Camera unavailable",
|
||||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps the device on the app-wide webcam identity. The old device is stopped
|
||||
// unconditionally; each scene that uses the webcam re-acquires the new one so
|
||||
// the per-config refcount stays honest. Only called when the device differs.
|
||||
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
|
||||
{
|
||||
var oldDevice = _webcam!.DeviceId;
|
||||
_webcam.DeviceId = device.Id;
|
||||
_webcam.Name = device.DisplayName;
|
||||
ScheduleSave();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
|
||||
await _cameraManager.ReleaseAllAsync(oldDevice);
|
||||
|
||||
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||||
{
|
||||
var started = await _cameraManager.AcquireAsync(device.Id);
|
||||
if (!started)
|
||||
{
|
||||
_notifications.Warning("Camera unavailable",
|
||||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Change Webcam…" from the webcam's context menu: picker, then swap the
|
||||
// app-wide identity if a different device was chosen.
|
||||
private async Task ChangeWebcamAsync()
|
||||
{
|
||||
if (_webcam == null) return;
|
||||
|
||||
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||||
{
|
||||
Owner = Application.Current.MainWindow
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||||
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
|
||||
|
||||
await SwapWebcamIdentityAsync(dialog.PickedDevice);
|
||||
}
|
||||
|
||||
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
|
||||
// existing (hidden) config — the config row survives Hide in this scene — or
|
||||
// adds the webcam here for the first time (which opens the camera picker).
|
||||
private void ShowWebcamInStagedScene()
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null) return;
|
||||
|
||||
var config = scene.WebcamConfig;
|
||||
if (config != null)
|
||||
{
|
||||
config.IsVisible = true;
|
||||
SelectedElement = config;
|
||||
return;
|
||||
}
|
||||
|
||||
_ = AddWebcamToStagedSceneAsync();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+53
-5
@@ -18,15 +18,63 @@ for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Hel
|
||||
## MainViewModel partial split (in progress)
|
||||
|
||||
`MainViewModel.cs` is being split into one file per functional area, zero behavior
|
||||
change, one commit per partial (each commit a roll-back point):
|
||||
change, one commit per partial (each commit a roll-back point). **Commit naming:**
|
||||
titles carry the series id (`Commit N/12`), tags are `refactor-commit-N` — e.g.
|
||||
`git show refactor-commit-1`. Every partial lands with a `ViewModels/index.md`
|
||||
update in the SAME commit.
|
||||
|
||||
Progress (Commit N/12 · tag):
|
||||
- **Commit 1/12 · `refactor-commit-1`: Account landed** → `MainViewModel.Account.cs`
|
||||
(session restore `LoadSavedSessionAsync`, sign-in/out/change account,
|
||||
`IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip`).
|
||||
- **Commit 2/12 · `refactor-commit-2`: License landed** → `MainViewModel.License.cs`
|
||||
(Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/
|
||||
`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`,
|
||||
`ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`).
|
||||
- **Commit 3/12 · `refactor-commit-3`: Scenes landed** → `MainViewModel.Scenes.cs`
|
||||
(`Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, transitions
|
||||
`SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/
|
||||
`ApplyTransitionToLive`, global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/
|
||||
`RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/
|
||||
`CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`,
|
||||
`AddScene`).
|
||||
- **Commit 4/12 · `refactor-commit-4`: Background landed** → `MainViewModel.Background.cs`
|
||||
(background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/
|
||||
`HealBackgrounds`/`StampDefaultBackgroundAsset`, display picker `Displays` +
|
||||
`SetBackgroundCapture`, screen capture `ReacquireScreenCaptures`/
|
||||
`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/
|
||||
`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/
|
||||
`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop`, preview fns
|
||||
`ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/
|
||||
`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`,
|
||||
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
||||
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
||||
stays in core.
|
||||
- **Commit 5/12 · `refactor-commit-5`: Webcam landed** → `MainViewModel.Webcam.cs`
|
||||
(webcam identity `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`, per-scene
|
||||
configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`, capture lifetime
|
||||
`OnCameraPreviewBitmapChanged`/`OnCameraFailed`, gating `CanAddWebcam`/
|
||||
`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`,
|
||||
size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`,
|
||||
`AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`).
|
||||
- **Commit 6/12 · `refactor-commit-6`: Audio landed** → `MainViewModel.Audio.cs`
|
||||
(mic bar `AudioLevel`/`MeterFillWidth`/`MeterBrush`/`MicVolume`/`MicMuted`/
|
||||
`MicMuteText`/`MicSourceName`/`ToggleMicMute`/`PickMicrophone`, volume flash
|
||||
`SetVolumeAdjusting`/`BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`,
|
||||
mic status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip`/`MicProblemBrush`,
|
||||
game bar `GameAudioLevel`/`GameMeterLevel`/`GameMeterFillWidth`/`GameMeterBrush`/
|
||||
`SetGameVolumeAdjusting`/`GameAudioVolume`/`GameMuted`/`GameMuteText`/
|
||||
`ToggleGameMute`, `PushSystemVolume`, mixer handlers `OnMicLevelChanged`/
|
||||
`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`,
|
||||
audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`).
|
||||
TRAX stays in core (next commit).
|
||||
|
||||
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||||
- Partials: `Account` (sign-in/session/avatar), `License` (Polar key validation + `IsPremium`),
|
||||
`Scenes` (scene catalog + hotkeys + transitions), `Background` (background model + screen
|
||||
capture + display picker), `Webcam` (webcam identity + per-scene configs + captures),
|
||||
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
||||
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
||||
`Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6),
|
||||
`Trax` (music player), `Socials` (social bar + dialog),
|
||||
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||||
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
||||
panels + license entry).
|
||||
|
||||
Reference in New Issue
Block a user