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:
+54
-4
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user