refactor: split Trax out of MainViewModel — Commit 7/12
MainViewModel.Trax.cs: music-player state (_musicPlayer/_music/_musicPlaying, TraxIdleBrush), TRAX UI (IsMusicPlaying/TraxError/TraxDotBrush/TraxToolTip), interaction (TraxLeftClick/TraxRightClick/PickTraxTrack), playback (LoadTrax/PlayTrax/PauseTrax/OnTraxPlaybackEnded/RefreshTraxUi), local loopback ducking (UpdateTraxLocalGain). Core keeps the ctor event hookup, LoadLayout/Shutdown/SaveLayoutNow call sites, and Scenes partial's hotkey dispatch. Verify: 0 warnings, 246 passed, 2 known failures only.
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
|
|
||||||
// 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 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;
|
||||||
@@ -234,121 +228,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+9
-3
@@ -68,14 +68,20 @@ Progress (Commit N/12 · tag):
|
|||||||
`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`,
|
`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`,
|
||||||
audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`).
|
audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`).
|
||||||
TRAX stays in core (next commit).
|
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` ✔ (Commit 5), `Audio` ✔ (Commit 6),
|
`Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6), `Trax` ✔ (Commit 7),
|
||||||
`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