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(); /// Test seam: a fake TRAX player (no audio device). Set before constructing. internal static Func? TraxPlayerOverride { get; set; } private Music? _music; private bool _musicPlaying; private bool _traxLoadInFlight; /// Hard ceiling on the TRAX reachability probe. A track on an offline /// network share blocks ~40s inside MediaFoundationReader 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. internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2); private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555"); /// True while the TRAX background track is playing. public bool IsMusicPlaying { get => _musicPlaying; private set { if (SetProperty(ref _musicPlaying, value)) { OnPropertyChanged(nameof(TraxDotBrush)); OnPropertyChanged(nameof(TraxToolTip)); } } } private string? _traxError; /// Error message from the last TRAX load/playback failure; null = no error. public string? TraxError { get => _traxError; private set { if (SetProperty(ref _traxError, value)) OnPropertyChanged(nameof(TraxDotBrush)); } } /// TRAX status dot: green = playing, red = the last load/play failed (the /// message scrolls in the desktop-audio bar), gray = idle or no track. public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying ? BarOnBrush : TraxError != null ? BarOffBrush : TraxIdleBrush; /// 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.) public const double TraxDefaultDb = -50; public const double TraxStepDb = 5; /// +6 dB is the ceiling; −60 dB is silence. The buttons disable at both ends. public bool CanRaiseTrax => TraxVolumeDb < Helpers.GainDb.MaxDb; public bool CanLowerTrax => TraxVolumeDb > Helpers.GainDb.FloorDb; public void StepTrax(int steps) => TraxVolumeDb += steps * TraxStepDb; /// The dock's TRAX level, in dB. Unity means the bed's design level, not the raw file. 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"; /// The TRAX ladder: the player's own measured output, so "is music actually making /// sound" is answerable without hearing it. public double TraxMeterFraction => IsMusicPlaying ? Services.Audio.AudioLevelMeter.ToDisplay(_musicPlayer.Level) : 0; /// Track name, or an honest "No track". public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track"; /// Same ducker signal as the game bay — the bed ducks with it. 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"; /// Left-click on TRAX: no track → pick one; otherwise toggle play/pause. public void TraxLeftClick() { if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath)) PickTraxTrack(); else if (IsMusicPlaying) PauseTrax(); else PlayTrax(); } /// Right-click on TRAX: always open the track picker (never the OS viewer). public void TraxRightClick() => PickTraxTrack(); /// Open the track picker (the dock's right-click on the TRAX name). 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); } /// 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. internal Task SetTraxTrackAsync(string path, bool play) { _music = new Music { TrackPath = path, IsEnabled = true }; return LoadTraxAsync(play); } /// Probes a path off the UI thread and gives up at . /// 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. internal static async Task IsTrackReachableAsync(string path, TimeSpan budget, Func 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)); } /// 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. private void UpdateTraxLocalGain() { _musicPlayer.LocalGain = GameMuted ? 0f : 1f; } }