Creator ruling (2026-10-30): TRAX is background music and is controlled separately from desktop and mic — so KISS it: - TRAX loses its own ladder and slider. It is one number plus ±5 dB buttons, disabled at the ends (−60 dB floor, +6 dB ceiling). - It STARTS at −50 dB, taken from the creator's own meter readings, and moves in 5 dB clicks. If that proves inaudible on their setup, six clicks lands at −20 — the buttons exist for exactly that, and `TraxDefaultDb` is one constant. - Layout follows: Desktop's volume now sits directly beneath its ladder, and the TRAX row (name, play/pause, level) sits beneath that, inside the GAME bay. The bays are MIC | GAME+TRAX | LIP-SYNC. - The metering tap on the player stays (it is how "is the music producing sound" is answered) but has no ladder of its own; the desktop bed already shows the music. Guards updated: TraxBayTests now asserts the stepper wiring and that the row has NO SegmentMeter; GameBayTests asserts the desktop fader sits below the desktop ladder.
276 lines
10 KiB
C#
276 lines
10 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;
|
||
|
||
/// <summary>Where TRAX starts: quiet background under the desktop bed. The ±5 dB buttons are the
|
||
/// control surface — a creator who wants it louder clicks up three times rather than hunting a
|
||
/// 6px thumb on a 148px dock. (Creator ruling 2026-10-30, from their own meter readings.)</summary>
|
||
public const double TraxDefaultDb = -50;
|
||
|
||
public const double TraxStepDb = 5;
|
||
|
||
/// <summary>+6 dB is the ceiling; −60 dB is silence. The buttons disable at both ends.</summary>
|
||
public bool CanRaiseTrax => TraxVolumeDb < Helpers.GainDb.MaxDb;
|
||
|
||
public bool CanLowerTrax => TraxVolumeDb > Helpers.GainDb.FloorDb;
|
||
|
||
public void StepTrax(int steps) => TraxVolumeDb += steps * TraxStepDb;
|
||
|
||
/// <summary>The dock's TRAX level, in dB. Unity means the bed's design level, not the raw file.</summary>
|
||
public double TraxVolumeDb
|
||
{
|
||
get => Helpers.GainDb.ToDb(_musicPlayer.UserGain);
|
||
set
|
||
{
|
||
var gain = Helpers.GainDb.FromDb(value);
|
||
if (Math.Abs(gain - _musicPlayer.UserGain) < 1e-6) return;
|
||
_musicPlayer.UserGain = (float)gain;
|
||
OnPropertyChanged(nameof(TraxVolumeDb));
|
||
OnPropertyChanged(nameof(TraxVolumeDbLabel));
|
||
OnPropertyChanged(nameof(CanRaiseTrax));
|
||
OnPropertyChanged(nameof(CanLowerTrax));
|
||
}
|
||
}
|
||
|
||
public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB";
|
||
|
||
/// <summary>The TRAX ladder: the player's own measured output, so "is music actually making
|
||
/// sound" is answerable without hearing it.</summary>
|
||
public double TraxMeterFraction =>
|
||
IsMusicPlaying ? Services.Audio.AudioLevelMeter.ToDisplay(_musicPlayer.Level) : 0;
|
||
|
||
/// <summary>Track name, or an honest "No track".</summary>
|
||
public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track";
|
||
|
||
/// <summary>Same ducker signal as the game bay — the bed ducks with it.</summary>
|
||
public bool IsTraxDucking => IsGameDucking;
|
||
|
||
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();
|
||
|
||
/// <summary>Open the track picker (the dock's right-click on the TRAX name).</summary>
|
||
internal void ChooseTrack() => 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;
|
||
}
|
||
}
|