Files
LlamaCasty/ViewModels/MainViewModel.Trax.cs
T

230 lines
8.2 KiB
C#

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 button is a
// toggle; its dot is green (playing) / red (error — a terse message scrolls in the
// desktop-audio bar) / gray (idle or no track).
private readonly ITraxPlayer _musicPlayer = TraxPlayerOverride?.Invoke() ?? new MusicPlayer();
/// <summary>Test seam: a fake TRAX player (no audio device). Set before constructing.</summary>
internal static Func<ITraxPlayer>? TraxPlayerOverride { get; set; }
private Music? _music;
private bool _musicPlaying;
private bool _traxLoadInFlight;
/// <summary>Hard ceiling on the TRAX reachability probe. A track on an offline
/// network share blocks ~40s inside <c>MediaFoundationReader</c> before it throws
/// 0x80070035, and that call sat on the startup path inside LoadLayout, so the
/// window froze for the whole SMB timeout. The probe runs off the UI thread and is
/// abandoned at this budget; the orphan thread stays parked until SMB gives up,
/// which is the point — it is not the thread the creator is waiting on.</summary>
internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2);
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
{
if (SetProperty(ref _traxError, value))
OnPropertyChanged(nameof(TraxDotBrush));
}
}
/// <summary>TRAX status dot: green = playing, red = the last load/play failed (the
/// message scrolls in the desktop-audio bar), gray = idle or no track.</summary>
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
? BarOnBrush
: TraxError != null ? BarOffBrush : 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;
_ = SetTraxTrackAsync(dialog.FileName, play: true);
}
/// <summary>Points TRAX at a track and loads it (optionally playing). The picker's path;
/// internal so tests can load a track without the file dialog.</summary>
internal Task SetTraxTrackAsync(string path, bool play)
{
_music = new Music { TrackPath = path, IsEnabled = true };
return LoadTraxAsync(play);
}
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
/// A probe may time out — that is the whole point. False means offline, missing, or
/// slower than the budget, and every one of those is a normal state, not an error.</summary>
internal static async Task<bool> IsTrackReachableAsync(string path, TimeSpan budget, Func<string, bool> probe)
{
var probeTask = Task.Run(() =>
{
try
{
return probe(path);
}
catch
{
return false;
}
});
var finished = await Task.WhenAny(probeTask, Task.Delay(budget)).ConfigureAwait(false);
return finished == probeTask && await probeTask.ConfigureAwait(false);
}
private void LoadTrax(bool play) => _ = LoadTraxAsync(play);
private async Task LoadTraxAsync(bool play)
{
var music = _music;
if (music == null || string.IsNullOrWhiteSpace(music.TrackPath) || _traxLoadInFlight)
return;
_traxLoadInFlight = true;
var loaded = false;
try
{
var path = music.TrackPath;
if (await IsTrackReachableAsync(path, TraxProbeBudget, File.Exists))
{
// Reached the share, so this bind is cheap and stays on the UI thread
// with every other MusicPlayer mutation — no new race to reason about.
_musicPlayer.Load(path);
UpdateTraxLocalGain();
music.IsEnabled = true;
TraxError = null;
loaded = true;
if (play)
PlayTrax();
}
else
{
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
TraxError = "TRAX: can't reach the track";
}
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
TraxError = "TRAX: can't load the track";
}
finally
{
_traxLoadInFlight = false;
RefreshTraxUi();
if (loaded)
ScheduleSave();
}
}
// Both toggle halves MUST refresh the UI: the dot and IsMusicPlaying used to update only
// on load/playback-ended, so pressing TRAX started the music with the light still off —
// and because IsMusicPlaying stayed false, the next press tried to play again instead of
// pausing (2026-10-09 bug).
private void PlayTrax()
{
try
{
_musicPlayer.Play();
TraxError = _musicPlayer.IsPlaying ? null : "TRAX: no audio output";
if (TraxError != null) AppLog.Write("TRAX: Play() returned but playback did not start (no output device?)");
}
catch (Exception ex)
{
AppLog.Write($"TRAX: play failed: {ex.Message}");
TraxError = "TRAX: can't play the track";
}
RefreshTraxUi();
}
private void PauseTrax()
{
try
{
_musicPlayer.Pause();
}
catch (Exception ex)
{
AppLog.Write($"TRAX: pause failed: {ex.Message}");
}
RefreshTraxUi();
}
private void OnTraxPlaybackEnded(Exception? ex)
{
if (ex != null)
{
AppLog.Write($"TRAX: playback error: {ex.Message}");
TraxError = "TRAX: playback stopped";
}
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;
}
}