feat(dock): the TRAX bay — track, play/pause, level, duck badge

Unit 7. The bay shows the track name (left-click toggles playback, right-click opens the picker —
the label stays the control), a play/pause button, the duck badge, and a dB level.

No ladder here, deliberately: nothing measures the music bed, and a decorative ladder would break
the measured-not-painted rule. A test asserts the bay has no SegmentMeter.

The level is real: `ITraxPlayer.UserGain` is a new creator gain multiplied onto the fixed design
volume, separate from `LocalGain` (which follows the desktop mute). The VM's `TraxVolumeDb` is the
same dB scale as the other faders, so unity means the bed's design level.
This commit is contained in:
2026-10-30 13:15:17 -07:00
parent a1d8e761ea
commit 7339df5fcd
7 changed files with 154 additions and 5 deletions
+54 -4
View File
@@ -114,10 +114,60 @@
</Grid>
</Border>
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
<StackPanel>
<TextBlock Text="TRAX" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
<TextBlock x:Name="TraxBayBody" Text="—" FontSize="11" Foreground="{StaticResource Brush.OnSurfaceDisabled}"/>
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="TRAX" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<!-- The name is the control: left-click toggles play, right-click picks (§TRAX). -->
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurface}"
Text="{Binding TraxTrackLabel}"
MouseLeftButtonUp="TraxLabel_MouseLeftButtonUp"
MouseRightButtonUp="TraxLabel_MouseRightButtonUp" Cursor="Hand"/>
<Button Grid.Column="2" Width="22" Height="18" Style="{StaticResource YtButtonSecondary}" Padding="0"
Margin="4,0,0,0" Click="TraxPlayPause_Click" ToolTip="{Binding TraxToolTip}">
<TextBlock FontSize="10" Foreground="{StaticResource Brush.OnSurface}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="▶"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMusicPlaying}" Value="True">
<Setter Property="Text" Value="❚❚"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<Border Grid.Column="3" Margin="4,0,0,0" Background="{StaticResource Brush.SurfaceContainerHighest}"
CornerRadius="{StaticResource Radius.Sm}" Padding="5,1"
Visibility="{Binding IsTraxDucking, Converter={StaticResource BoolToVis}}">
<TextBlock FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Secondary}" Text="DUCK"/>
</Border>
</Grid>
<DockPanel Grid.Row="1" Margin="0,6,0,0" VerticalAlignment="Bottom">
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding TraxVolumeDbLabel}"/>
<Slider Minimum="-60" Maximum="6" SmallChange="0.5" LargeChange="3"
Value="{Binding TraxVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
</Grid>
</Border>
<Border Grid.Column="3" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0,0,0" Padding="10,6">
<StackPanel>
+15
View File
@@ -39,6 +39,21 @@ public partial class AudioDock : UserControl
vm.OpenMicPickerCommand.Execute(null);
}
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.ChooseTrack();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
_window = Window.GetWindow(this);
+4
View File
@@ -8,6 +8,10 @@ public interface ITraxPlayer : IDisposable
string? TrackName { get; }
bool IsPlaying { get; }
float LocalGain { get; set; }
/// <summary>The creator's TRAX level (1 = the bed's fixed design level). Separate from
/// <see cref="LocalGain"/>, which follows the desktop mute; the dock's fader moves this.</summary>
float UserGain { get; set; }
void Load(string path);
void Play();
void Pause();
+14 -1
View File
@@ -41,6 +41,19 @@ public sealed class MusicPlayer : ITraxPlayer
/// <summary>Local gain multiplier from the desktop/game audio bar (0 = mute,
/// 1 = full bed). Applied live on top of the fixed <see cref="MusicVolume"/>.</summary>
private float _userGain = 1f;
/// <summary>Creator-set level, multiplied onto the fixed design volume.</summary>
public float UserGain
{
get => _userGain;
set
{
_userGain = Math.Clamp(value, 0f, 2f);
if (_output != null) _output.Volume = MusicVolume * _userGain * LocalGain;
}
}
public float LocalGain
{
get => _localGain;
@@ -61,7 +74,7 @@ public sealed class MusicPlayer : ITraxPlayer
Stop();
TrackPath = path;
_reader = new MediaFoundationReader(path);
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _userGain * _localGain };
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_volume);
+25
View File
@@ -63,6 +63,28 @@ public partial class MainViewModel
? BarOnBrush
: TraxError != null ? BarOffBrush : TraxIdleBrush;
/// <summary>The dock's TRAX fader, 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));
}
}
public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB";
/// <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";
@@ -81,6 +103,9 @@ public partial class MainViewModel
/// <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
+41
View File
@@ -0,0 +1,41 @@
using Xunit;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>The TRAX bay shows the track, plays/pauses it, and carries a level — but no meter:
/// nothing measures the bed, and a decorative ladder would be a lie. Its fader moves a gain the
/// player actually applies, separate from the desktop-mute follow.
/// </summary>
public class TraxBayTests
{
[Fact]
public void The_trax_bay_binds_the_track_level_and_play_state_without_inventing_a_meter()
{
var dock = SourceAssert.Text("Controls", "AudioDock.xaml");
Assert.Contains("Text=\"{Binding TraxTrackLabel}\"", dock);
Assert.Contains("Value=\"{Binding TraxVolumeDb, Mode=TwoWay}\"", dock);
Assert.Contains("Text=\"{Binding TraxVolumeDbLabel}\"", dock);
Assert.Contains("Binding=\"{Binding IsMusicPlaying}\" Value=\"True\"", dock);
Assert.Contains("Click=\"TraxPlayPause_Click\"", dock);
// No ladder in the TRAX bay: nothing measures the bed, so there is nothing honest to show.
var traxBay = dock[dock.IndexOf("Text=\"TRAX\"", System.StringComparison.Ordinal)
..dock.IndexOf("Text=\"LIP-SYNC\"", System.StringComparison.Ordinal)];
Assert.Contains("TraxTrackLabel", traxBay);
Assert.DoesNotContain("SegmentMeter", traxBay);
foreach (var name in new[] { "TraxVolumeDb", "TraxVolumeDbLabel", "TraxTrackLabel", "IsTraxDucking" })
Assert.NotNull(typeof(MainViewModel).GetProperty(name));
// The player gained a real user level, separate from the mute-follow LocalGain.
Assert.NotNull(typeof(ITraxPlayer).GetProperty("UserGain"));
Assert.NotNull(typeof(MusicPlayer).GetProperty("UserGain"));
// Unity means the bed's design level, and the fader's scale is the same one as the others.
Assert.Equal(0, GainDb.ToDb(1f), 6);
Assert.Contains("∞", GainDb.Label(GainDb.FloorDb)); // true silence, not "-60.0"
}
}
+1
View File
@@ -21,6 +21,7 @@ public sealed class TraxToggleTests
public string? TrackName { get; private set; }
public bool IsPlaying { get; private set; }
public float LocalGain { get; set; } = 1f;
public float UserGain { get; set; } = 1f;
public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
public void Play() => IsPlaying = Starts;
public void Pause() => IsPlaying = false;