Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 535a50192c | |||
| c1126a52b9 | |||
| fc33022006 | |||
| 46978b8bc3 | |||
| 58e7b3f3d1 | |||
| e2867198b9 |
@@ -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,
|
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
|
||||||
not git mechanics.
|
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:
|
MUST read the architecture guide before planning or modifying the codebase:
|
||||||
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
|
[`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.
|
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
|
## 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
|
## Landmines
|
||||||
|
|
||||||
@@ -35,11 +52,12 @@ Nothing — clean tree, ready for next task.
|
|||||||
- TASK 22 (Audio sync offset): not started — required for v1.
|
- 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`.
|
- TASK 30: gear cleanup + record location + single Start + preview layout restructure — shipped across commits `6031a56` + `61b850f` + `455a409`.
|
||||||
- Monetization: deferred (creator rethinking price/subscription model).
|
- 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
|
- 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).
|
||||||
- ai.md updated: TASK 4 steps 5-7 added as shipped, stale "frame-pipeline wiring follows" removed
|
- 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.
|
||||||
- HANDOFF.md todo state expanded with full statuses for all active tasks
|
- Mea culpa: asked the user a bucket-boundary question that the rules already answered — won't repeat; buckets are fixed.
|
||||||
- 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
|
- 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,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,132 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services.Audio;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
||||||
|
// the default device so it rides the loopback into the stream. The dot is
|
||||||
|
// red (no track) / yellow (loaded, stopped) / green (playing).
|
||||||
|
private readonly MusicPlayer _musicPlayer = new();
|
||||||
|
private Music? _music;
|
||||||
|
private bool _musicPlaying;
|
||||||
|
|
||||||
|
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
||||||
|
/// <summary>True while the TRAX background track is playing.</summary>
|
||||||
|
public bool IsMusicPlaying
|
||||||
|
{
|
||||||
|
get => _musicPlaying;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _musicPlaying, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(TraxDotBrush));
|
||||||
|
OnPropertyChanged(nameof(TraxToolTip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _traxError;
|
||||||
|
|
||||||
|
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
|
||||||
|
public string? TraxError
|
||||||
|
{
|
||||||
|
get => _traxError;
|
||||||
|
private set => SetProperty(ref _traxError, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
|
||||||
|
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
||||||
|
? BarOnBrush
|
||||||
|
: TraxIdleBrush;
|
||||||
|
|
||||||
|
public string TraxToolTip => _musicPlayer.TrackName != null
|
||||||
|
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
||||||
|
: "No track — left-click or right-click to choose background music";
|
||||||
|
|
||||||
|
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
|
||||||
|
public void TraxLeftClick()
|
||||||
|
{
|
||||||
|
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
||||||
|
PickTraxTrack();
|
||||||
|
else if (IsMusicPlaying)
|
||||||
|
PauseTrax();
|
||||||
|
else
|
||||||
|
PlayTrax();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
||||||
|
public void TraxRightClick() => PickTraxTrack();
|
||||||
|
|
||||||
|
private void PickTraxTrack()
|
||||||
|
{
|
||||||
|
var dialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Choose background music",
|
||||||
|
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
|
||||||
|
LoadTrax(play: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadTrax(bool play)
|
||||||
|
{
|
||||||
|
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_musicPlayer.Load(_music.TrackPath);
|
||||||
|
UpdateTraxLocalGain();
|
||||||
|
_music.IsEnabled = true;
|
||||||
|
TraxError = null;
|
||||||
|
if (play)
|
||||||
|
_musicPlayer.Play();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
|
||||||
|
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayTrax() => _musicPlayer.Play();
|
||||||
|
|
||||||
|
private void PauseTrax() => _musicPlayer.Pause();
|
||||||
|
|
||||||
|
private void OnTraxPlaybackEnded(Exception? ex)
|
||||||
|
{
|
||||||
|
if (ex != null)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
||||||
|
TraxError = $"Playback error: {ex.Message}";
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshTraxUi()
|
||||||
|
{
|
||||||
|
IsMusicPlaying = _musicPlayer.IsPlaying;
|
||||||
|
OnPropertyChanged(nameof(TraxDotBrush));
|
||||||
|
OnPropertyChanged(nameof(TraxToolTip));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
|
||||||
|
/// music so the creator hears the controls work in the headphones (the
|
||||||
|
/// track rides the loopback channel, so it must duck the same way locally).
|
||||||
|
/// Called whenever the game volume/mute change and on TRAX load.</summary>
|
||||||
|
private void UpdateTraxLocalGain()
|
||||||
|
{
|
||||||
|
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,33 +27,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly YouTubeStreamService _youtubeStream;
|
private readonly YouTubeStreamService _youtubeStream;
|
||||||
private readonly YouTubeChatService _youtubeChat;
|
private readonly YouTubeChatService _youtubeChat;
|
||||||
private readonly DispatcherTimer _liveTimer;
|
private readonly DispatcherTimer _liveTimer;
|
||||||
private readonly DispatcherTimer _volumeFlashTimer;
|
|
||||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
|
||||||
private readonly DispatcherTimer _healthPollTimer;
|
private readonly DispatcherTimer _healthPollTimer;
|
||||||
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
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;
|
|
||||||
|
|
||||||
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
|
||||||
// the default device so it rides the loopback into the stream. The dot is
|
|
||||||
// red (no track) / yellow (loaded, stopped) / green (playing).
|
|
||||||
private readonly MusicPlayer _musicPlayer = new();
|
|
||||||
private Music? _music;
|
|
||||||
private bool _musicPlaying;
|
|
||||||
private AudioGainProvider? _gainProvider;
|
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
private StreamHealth _currentHealth = new();
|
private StreamHealth _currentHealth = new();
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
@@ -106,24 +83,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// news up the real service; the VM is built from XAML so no DI seam exists.
|
// news up the real service; the VM is built from XAML so no DI seam exists.
|
||||||
private readonly INotificationService _notifications = new NotificationService();
|
private readonly INotificationService _notifications = new NotificationService();
|
||||||
|
|
||||||
// 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;
|
|
||||||
private SocialsConfig? _socials;
|
private SocialsConfig? _socials;
|
||||||
private VideoFrame? _socialBarFrame;
|
private VideoFrame? _socialBarFrame;
|
||||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
|
||||||
|
|
||||||
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
|
|
||||||
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
|
|
||||||
// lifetime (started at startup, stopped on shutdown) so the footer meters
|
|
||||||
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
|
|
||||||
// feeds the game audio bar's meter. Private by design — the mixer surfaces
|
|
||||||
// the levels + mic connection state to the UI.
|
|
||||||
private readonly AudioMixer _audioMixer;
|
|
||||||
|
|
||||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||||
@@ -202,26 +163,12 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
||||||
/// anywhere — one chat overlay per layout.</summary>
|
/// anywhere — one chat overlay per layout.</summary>
|
||||||
public bool CanAddYouTubeChat
|
public bool CanAddYouTubeChat
|
||||||
=> StagedScene != null
|
=> StagedScene != null
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
/// <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;
|
|
||||||
|
|
||||||
// ─── Social bar (global resource, shown on every scene) ───
|
// ─── Social bar (global resource, shown on every scene) ───
|
||||||
|
|
||||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||||
@@ -249,25 +196,11 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
||||||
|
|
||||||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
|
||||||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
|
||||||
|
|
||||||
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
||||||
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
||||||
&& !el.IsDraggable && !el.IsWebcam
|
&& !el.IsDraggable && !el.IsWebcam
|
||||||
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
||||||
|
|
||||||
/// <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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamStatus StreamStatus
|
public StreamStatus StreamStatus
|
||||||
{
|
{
|
||||||
get => _streamStatus;
|
get => _streamStatus;
|
||||||
@@ -295,429 +228,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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");
|
|
||||||
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
|
||||||
|
|
||||||
/// <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). ───
|
|
||||||
|
|
||||||
/// <summary>True while the TRAX background track is playing.</summary>
|
|
||||||
public bool IsMusicPlaying
|
|
||||||
{
|
|
||||||
get => _musicPlaying;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _musicPlaying, value))
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(TraxDotBrush));
|
|
||||||
OnPropertyChanged(nameof(TraxToolTip));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _traxError;
|
|
||||||
|
|
||||||
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
|
|
||||||
public string? TraxError
|
|
||||||
{
|
|
||||||
get => _traxError;
|
|
||||||
private set => SetProperty(ref _traxError, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
|
|
||||||
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
|
||||||
? BarOnBrush
|
|
||||||
: TraxIdleBrush;
|
|
||||||
|
|
||||||
public string TraxToolTip => _musicPlayer.TrackName != null
|
|
||||||
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
|
||||||
: "No track — left-click or right-click to choose background music";
|
|
||||||
|
|
||||||
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
|
|
||||||
public void TraxLeftClick()
|
|
||||||
{
|
|
||||||
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
|
||||||
PickTraxTrack();
|
|
||||||
else if (IsMusicPlaying)
|
|
||||||
PauseTrax();
|
|
||||||
else
|
|
||||||
PlayTrax();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
|
||||||
public void TraxRightClick() => PickTraxTrack();
|
|
||||||
|
|
||||||
private void PickTraxTrack()
|
|
||||||
{
|
|
||||||
var dialog = new OpenFileDialog
|
|
||||||
{
|
|
||||||
Title = "Choose background music",
|
|
||||||
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
|
|
||||||
};
|
|
||||||
if (dialog.ShowDialog() != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
|
|
||||||
LoadTrax(play: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadTrax(bool play)
|
|
||||||
{
|
|
||||||
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_musicPlayer.Load(_music.TrackPath);
|
|
||||||
UpdateTraxLocalGain();
|
|
||||||
_music.IsEnabled = true;
|
|
||||||
TraxError = null;
|
|
||||||
if (play)
|
|
||||||
_musicPlayer.Play();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
|
|
||||||
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
|
|
||||||
}
|
|
||||||
RefreshTraxUi();
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PlayTrax() => _musicPlayer.Play();
|
|
||||||
|
|
||||||
private void PauseTrax() => _musicPlayer.Pause();
|
|
||||||
|
|
||||||
private void OnTraxPlaybackEnded(Exception? ex)
|
|
||||||
{
|
|
||||||
if (ex != null)
|
|
||||||
{
|
|
||||||
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
|
||||||
TraxError = $"Playback error: {ex.Message}";
|
|
||||||
}
|
|
||||||
RefreshTraxUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshTraxUi()
|
|
||||||
{
|
|
||||||
IsMusicPlaying = _musicPlayer.IsPlaying;
|
|
||||||
OnPropertyChanged(nameof(TraxDotBrush));
|
|
||||||
OnPropertyChanged(nameof(TraxToolTip));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
|
|
||||||
/// music so the creator hears the controls work in the headphones (the
|
|
||||||
/// track rides the loopback channel, so it must duck the same way locally).
|
|
||||||
/// Called whenever the game volume/mute change and on TRAX load.</summary>
|
|
||||||
private void UpdateTraxLocalGain()
|
|
||||||
{
|
|
||||||
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -973,57 +483,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private const double MasterFrameWidth = 1920;
|
private const double MasterFrameWidth = 1920;
|
||||||
private const double MasterFrameHeight = 1080;
|
private const double MasterFrameHeight = 1080;
|
||||||
|
|
||||||
// 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 QualityOption[] QualityOptions { get; } =
|
public QualityOption[] QualityOptions { get; } =
|
||||||
{
|
{
|
||||||
new("1080p60", 60, 8.0, 1920, 1080),
|
new("1080p60", 60, 8.0, 1920, 1080),
|
||||||
@@ -1123,16 +582,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// Commands
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
public ICommand AddWebcamCommand { get; }
|
|
||||||
public ICommand AddImageCommand { get; }
|
public ICommand AddImageCommand { get; }
|
||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand EditElementCommand { get; }
|
public ICommand EditElementCommand { get; }
|
||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { get; }
|
||||||
public ICommand ChangeWebcamCommand { get; }
|
|
||||||
public ICommand ShowWebcamCommand { get; }
|
|
||||||
public ICommand ToggleMicMuteCommand { get; }
|
|
||||||
public ICommand ToggleGameMuteCommand { get; }
|
|
||||||
public ICommand OpenMicPickerCommand { get; }
|
|
||||||
public ICommand OpenSocialDialogCommand { get; }
|
public ICommand OpenSocialDialogCommand { get; }
|
||||||
public ICommand StartStreamCommand { get; }
|
public ICommand StartStreamCommand { get; }
|
||||||
public ICommand EndStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
@@ -1399,54 +852,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("LoadLayout end");
|
AppLog.Write("LoadLayout end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RegisterLoadedWebSources()
|
private void RegisterLoadedWebSources()
|
||||||
{
|
{
|
||||||
if (_webView2Manager == null) return;
|
if (_webView2Manager == null) return;
|
||||||
@@ -1616,125 +1021,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
return $"{baseName}{i}";
|
return $"{baseName}{i}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddImage()
|
private void AddImage()
|
||||||
{
|
{
|
||||||
var scene = StagedScene;
|
var scene = StagedScene;
|
||||||
@@ -2339,68 +1625,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||||
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
||||||
|
|||||||
+27
-3
@@ -50,14 +50,38 @@ Progress (Commit N/12 · tag):
|
|||||||
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
||||||
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
||||||
stays in core.
|
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).
|
||||||
|
- **Commit 7/12 · `refactor-commit-7`: Trax landed** → `MainViewModel.Trax.cs`
|
||||||
|
(`_musicPlayer`/`_music`/`_musicPlaying` + `TraxIdleBrush`, `IsMusicPlaying`/
|
||||||
|
`TraxError`/`TraxDotBrush`/`TraxToolTip`, `TraxLeftClick`/`TraxRightClick`/
|
||||||
|
`PickTraxTrack`/`LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/
|
||||||
|
`RefreshTraxUi`/`UpdateTraxLocalGain`). Core keeps the ctor hookup
|
||||||
|
(`_musicPlayer.PlaybackEnded += ...`), the `LoadLayout`/`Shutdown`/
|
||||||
|
`SaveLayoutNow` call sites, and the Scenes partial's hotkey dispatch into `TraxLeftClick`.
|
||||||
|
|
||||||
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||||||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||||||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||||||
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
||||||
`Webcam` (webcam identity + per-scene configs + captures),
|
`Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6), `Trax` ✔ (Commit 7),
|
||||||
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
`Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||||||
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
|
||||||
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
||||||
panels + license entry).
|
panels + license entry).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user