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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
|
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
|
||||||
<StackPanel>
|
<Grid>
|
||||||
<TextBlock Text="TRAX" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
<Grid.RowDefinitions>
|
||||||
<TextBlock x:Name="TraxBayBody" Text="—" FontSize="11" Foreground="{StaticResource Brush.OnSurfaceDisabled}"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</StackPanel>
|
<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>
|
||||||
<Border Grid.Column="3" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0,0,0" Padding="10,6">
|
<Border Grid.Column="3" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0,0,0" Padding="10,6">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ public partial class AudioDock : UserControl
|
|||||||
vm.OpenMicPickerCommand.Execute(null);
|
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)
|
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_window = Window.GetWindow(this);
|
_window = Window.GetWindow(this);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ public interface ITraxPlayer : IDisposable
|
|||||||
string? TrackName { get; }
|
string? TrackName { get; }
|
||||||
bool IsPlaying { get; }
|
bool IsPlaying { get; }
|
||||||
float LocalGain { get; set; }
|
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 Load(string path);
|
||||||
void Play();
|
void Play();
|
||||||
void Pause();
|
void Pause();
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ public sealed class MusicPlayer : ITraxPlayer
|
|||||||
|
|
||||||
/// <summary>Local gain multiplier from the desktop/game audio bar (0 = mute,
|
/// <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>
|
/// 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
|
public float LocalGain
|
||||||
{
|
{
|
||||||
get => _localGain;
|
get => _localGain;
|
||||||
@@ -61,7 +74,7 @@ public sealed class MusicPlayer : ITraxPlayer
|
|||||||
Stop();
|
Stop();
|
||||||
TrackPath = path;
|
TrackPath = path;
|
||||||
_reader = new MediaFoundationReader(path);
|
_reader = new MediaFoundationReader(path);
|
||||||
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
|
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _userGain * _localGain };
|
||||||
_output = new WaveOutEvent();
|
_output = new WaveOutEvent();
|
||||||
_output.PlaybackStopped += OnPlaybackStopped;
|
_output.PlaybackStopped += OnPlaybackStopped;
|
||||||
_output.Init(_volume);
|
_output.Init(_volume);
|
||||||
|
|||||||
@@ -63,6 +63,28 @@ public partial class MainViewModel
|
|||||||
? BarOnBrush
|
? BarOnBrush
|
||||||
: TraxError != null ? BarOffBrush : TraxIdleBrush;
|
: 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
|
public string TraxToolTip => _musicPlayer.TrackName != null
|
||||||
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
||||||
: "No track — left-click or right-click to choose background music";
|
: "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>
|
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
||||||
public void TraxRightClick() => PickTraxTrack();
|
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()
|
private void PickTraxTrack()
|
||||||
{
|
{
|
||||||
var dialog = new OpenFileDialog
|
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 string? TrackName { get; private set; }
|
||||||
public bool IsPlaying { get; private set; }
|
public bool IsPlaying { get; private set; }
|
||||||
public float LocalGain { get; set; } = 1f;
|
public float LocalGain { get; set; } = 1f;
|
||||||
|
public float UserGain { get; set; } = 1f;
|
||||||
public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
|
public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
|
||||||
public void Play() => IsPlaying = Starts;
|
public void Play() => IsPlaying = Starts;
|
||||||
public void Pause() => IsPlaying = false;
|
public void Pause() => IsPlaying = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user