refactor: split Audio out of MainViewModel — Commit 6/12
MainViewModel.Audio.cs: mic bar (AudioLevel/MeterFillWidth/MeterBrush, MicVolume/MicMuted, ToggleMicMute, PickMicrophone), mic status dot (MicStatus/MicStatusBrush/MicStatusToolTip), game bar (GameAudioLevel/GameMeterFillWidth/GameMeterBrush, GameAudioVolume, GameMuted, ToggleGameMute), PushSystemVolume, mixer event handlers (OnMicLevelChanged/OnLoopbackLevelChanged/OnMicConnected/OnMicFailed), StartMicCaptureAsync, command properties, 18 fields. Kept in core: TraxIdleBrush, TRAX code block, ctor wiring (mixer init + event hookup), timer construction. Verify: 0 warnings, 246 passed, 2 known failures only.
This commit is contained in:
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,25 +27,9 @@ 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
|
// 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
|
// the default device so it rides the loopback into the stream. The dot is
|
||||||
@@ -53,7 +37,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly MusicPlayer _musicPlayer = new();
|
private readonly MusicPlayer _musicPlayer = new();
|
||||||
private Music? _music;
|
private Music? _music;
|
||||||
private bool _musicPlaying;
|
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;
|
||||||
@@ -108,15 +91,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
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
|
||||||
@@ -260,190 +234,7 @@ 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");
|
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>
|
/// <summary>True while the TRAX background track is playing.</summary>
|
||||||
public bool IsMusicPlaying
|
public bool IsMusicPlaying
|
||||||
{
|
{
|
||||||
@@ -557,131 +348,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
_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;
|
||||||
@@ -1041,9 +707,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
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 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; }
|
||||||
@@ -2083,68 +1746,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,
|
||||||
|
|||||||
+13
-2
@@ -57,13 +57,24 @@ Progress (Commit N/12 · tag):
|
|||||||
`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`,
|
`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`,
|
||||||
size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`,
|
size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`,
|
||||||
`AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`).
|
`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`,
|
- 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` ✔ (Commit 5),
|
`Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6),
|
||||||
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
`Trax` (music player), `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