[GUI] Added cross-platform playback menu music and title music volume control
This commit is contained in:
parent
1f4425bd9c
commit
85393c2ef8
|
|
@ -40,6 +40,8 @@ public sealed class GuiSettings
|
|||
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
||||
public bool PlayTitleMusic { get; set; } = true;
|
||||
|
||||
public double TitleMusicVolume { get; set; } = 1.0;
|
||||
|
||||
public string LibraryLayout { get; set; } = "Carousel";
|
||||
|
||||
public double EmbeddedConsoleHeight { get; set; } = 240;
|
||||
|
|
@ -148,6 +150,9 @@ public sealed class GuiSettings
|
|||
legacyProfile is { Length: 2 } ? legacyProfile[1] : settings.DefaultProfile);
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
|
||||
settings.TitleMusicVolume = double.IsFinite(settings.TitleMusicVolume)
|
||||
? Math.Clamp(settings.TitleMusicVolume, 0.0, 1.0)
|
||||
: 1.0;
|
||||
settings.ConsoleType = NormalizeChoice(settings.ConsoleType, "PS5");
|
||||
settings.ConsoleLanguage = NormalizeChoice(
|
||||
settings.ConsoleLanguage,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "معرّف اللعبة",
|
||||
"Common.Back": "رجوع",
|
||||
|
||||
"Library.Music.Volume": "مستوى صوت موسيقى اللعبة",
|
||||
"Library.Music.Mute": "كتم موسيقى اللعبة",
|
||||
"Library.Music.Unmute": "إلغاء كتم موسيقى اللعبة",
|
||||
|
||||
"Options.General": "عام",
|
||||
"Options.Logging": "التسجيل",
|
||||
"Options.Section.Emulation": "المحاكاة",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
"Library.Stat.TitleId": "Oyunun ID-si",
|
||||
"Common.Back": "Geri qayıt",
|
||||
|
||||
"Library.Music.Volume": "Oyun musiqisinin səs səviyyəsi",
|
||||
"Library.Music.Mute": "Oyun musiqisini susdur",
|
||||
"Library.Music.Unmute": "Oyun musiqisinin səsini aç",
|
||||
|
||||
"Options.General": "Ümumi",
|
||||
"Options.Logging": "Jurnala yazma",
|
||||
"Options.Env.Tab": "Mühit",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Library.Music.Volume": "Volume da música do jogo",
|
||||
"Library.Music.Mute": "Silenciar música do jogo",
|
||||
"Library.Music.Unmute": "Ativar som da música do jogo",
|
||||
|
||||
"Options.General": "Opções Gerais",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Zurück",
|
||||
|
||||
"Library.Music.Volume": "Lautstärke der Spielmusik",
|
||||
"Library.Music.Mute": "Spielmusik stummschalten",
|
||||
"Library.Music.Unmute": "Stummschaltung der Spielmusik aufheben",
|
||||
|
||||
"Options.General": "Allgemein",
|
||||
"Options.Logging": "Protokollierung",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "Titel-id",
|
||||
"Common.Back": "Tilbage",
|
||||
|
||||
"Library.Music.Volume": "Lydstyrke for spilmusik",
|
||||
"Library.Music.Mute": "Slå spilmusik fra",
|
||||
"Library.Music.Unmute": "Slå spilmusik til",
|
||||
|
||||
"Options.General": "Generelt",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULERING",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@
|
|||
"Library.Stat.TitleId": "Title ID",
|
||||
"Common.Back": "Tagasi",
|
||||
|
||||
"Library.Music.Volume": "Mängumuusika helitugevus",
|
||||
"Library.Music.Mute": "Vaigista mängumuusika",
|
||||
"Library.Music.Unmute": "Lülita mängumuusika heli sisse",
|
||||
|
||||
"Options.General": "Üldine",
|
||||
"Options.Logging": "Logimine",
|
||||
"Options.Env.Tab": "Keskkond",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@
|
|||
"Library.Stat.TitleId": "Title ID",
|
||||
"Common.Back": "Back",
|
||||
|
||||
"Library.Music.Volume": "Title music volume",
|
||||
"Library.Music.Mute": "Mute title music",
|
||||
"Library.Music.Unmute": "Unmute title music",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Env.Tab": "Environment",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "ID del título",
|
||||
"Common.Back": "Volver",
|
||||
|
||||
"Library.Music.Volume": "Volumen de la música del juego",
|
||||
"Library.Music.Mute": "Silenciar la música del juego",
|
||||
"Library.Music.Unmute": "Activar el sonido de la música del juego",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Section.Emulation": "EMULACIÓN",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
"Library.Stat.TitleId": "ID du titre",
|
||||
"Common.Back": "Retour",
|
||||
|
||||
"Library.Music.Volume": "Volume de la musique du jeu",
|
||||
"Library.Music.Mute": "Couper la musique du jeu",
|
||||
"Library.Music.Unmute": "Rétablir la musique du jeu",
|
||||
|
||||
"Options.General": "Général",
|
||||
"Options.Logging": "Journalisation",
|
||||
"Options.Env.Tab": "Environnement",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "Címazonosító",
|
||||
"Common.Back": "Vissza",
|
||||
|
||||
"Library.Music.Volume": "Játékzene hangereje",
|
||||
"Library.Music.Mute": "Játékzene némítása",
|
||||
"Library.Music.Unmute": "Játékzene visszakapcsolása",
|
||||
|
||||
"Options.General": "Általános",
|
||||
"Options.Logging": "Logolás",
|
||||
"Options.Env.Tab": "Környezet",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "ID titolo",
|
||||
"Common.Back": "Indietro",
|
||||
|
||||
"Library.Music.Volume": "Volume della musica del gioco",
|
||||
"Library.Music.Mute": "Disattiva audio della musica del gioco",
|
||||
"Library.Music.Unmute": "Riattiva audio della musica del gioco",
|
||||
|
||||
"Options.General": "Generale",
|
||||
"Options.Logging": "Log",
|
||||
"Options.Section.Emulation": "EMULAZIONE",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "タイトルID",
|
||||
"Common.Back": "戻る",
|
||||
|
||||
"Library.Music.Volume": "ゲーム音楽の音量",
|
||||
"Library.Music.Mute": "ゲーム音楽をミュート",
|
||||
"Library.Music.Unmute": "ゲーム音楽のミュートを解除",
|
||||
|
||||
"Options.General": "一般",
|
||||
"Options.Logging": "ロギング",
|
||||
"Options.Section.Emulation": "エミュレーション",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "타이틀 ID",
|
||||
"Common.Back": "뒤로",
|
||||
|
||||
"Library.Music.Volume": "게임 음악 볼륨",
|
||||
"Library.Music.Mute": "게임 음악 음소거",
|
||||
"Library.Music.Unmute": "게임 음악 음소거 해제",
|
||||
|
||||
"Options.General": "일반",
|
||||
"Options.Logging": "로깅",
|
||||
"Options.Section.Emulation": "에뮬레이션",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Terug",
|
||||
|
||||
"Library.Music.Volume": "Volume van gamemuziek",
|
||||
"Library.Music.Mute": "Gamemuziek dempen",
|
||||
"Library.Music.Unmute": "Dempen van gamemuziek opheffen",
|
||||
|
||||
"Options.General": "Algemeen",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULATIE",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@
|
|||
"Library.Stat.TitleId": "Numer seryjny",
|
||||
"Common.Back": "Wstecz",
|
||||
|
||||
"Library.Music.Volume": "Głośność muzyki w grze",
|
||||
"Library.Music.Mute": "Wycisz muzykę z gry",
|
||||
"Library.Music.Unmute": "Włącz dźwięk muzyki z gry",
|
||||
|
||||
"Options.General": "Ogólne",
|
||||
"Options.Logging": "Dziennik zdarzeń",
|
||||
"Options.Env.Tab": "Środowisko",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Library.Music.Volume": "Volume da música do jogo",
|
||||
"Library.Music.Mute": "Silenciar música do jogo",
|
||||
"Library.Music.Unmute": "Reativar som da música do jogo",
|
||||
|
||||
"Options.General": "Geral",
|
||||
"Options.Logging": "Registos",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@
|
|||
"Library.Stat.TitleId": "ID игры",
|
||||
"Common.Back": "Назад",
|
||||
|
||||
"Library.Music.Volume": "Громкость музыки игры",
|
||||
"Library.Music.Mute": "Выключить музыку игры",
|
||||
"Library.Music.Unmute": "Включить музыку игры",
|
||||
|
||||
"Options.General": "Основные",
|
||||
"Options.Logging": "Логгирование",
|
||||
"Options.Env.Tab": "Окружение",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
"Library.Stat.TitleId": "Başlık kimliği",
|
||||
"Common.Back": "Geri",
|
||||
|
||||
"Library.Music.Volume": "Oyun müziği ses seviyesi",
|
||||
"Library.Music.Mute": "Oyun müziğini kapat",
|
||||
"Library.Music.Unmute": "Oyun müziğini aç",
|
||||
|
||||
"Options.General": "Genel",
|
||||
"Options.Logging": "Günlükleme",
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,36 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
|
||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Border Background="Transparent"
|
||||
BorderBrush="{StaticResource CardBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="8"
|
||||
Height="32" Padding="2,0,6,0" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="2" VerticalAlignment="Center">
|
||||
<Button x:Name="TitleMusicMuteButton"
|
||||
Classes="iconGhost"
|
||||
Width="28" Height="28" BorderThickness="0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TitleMusicMuteGlyph"
|
||||
Classes="materialSymbol compact"
|
||||
Text="volume_up" />
|
||||
</Button>
|
||||
<Slider x:Name="TitleMusicVolumeSlider"
|
||||
Width="76" Height="28"
|
||||
Minimum="0" Maximum="100"
|
||||
SmallChange="1" LargeChange="10"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding [Library.Music.Volume], Source={x:Static local:Localization.Instance}}">
|
||||
<Slider.Resources>
|
||||
<GridLength x:Key="SliderPreContentMargin">7</GridLength>
|
||||
<GridLength x:Key="SliderPostContentMargin">7</GridLength>
|
||||
<x:Double x:Key="SliderHorizontalHeight">24</x:Double>
|
||||
<x:Double x:Key="SliderHorizontalThumbWidth">14</x:Double>
|
||||
<x:Double x:Key="SliderHorizontalThumbHeight">14</x:Double>
|
||||
<CornerRadius x:Key="SliderThumbCornerRadius">7</CornerRadius>
|
||||
</Slider.Resources>
|
||||
</Slider>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button x:Name="LibraryLayoutButton"
|
||||
Classes="iconGhost"
|
||||
VerticalAlignment="Center">
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ public partial class MainWindow : Window
|
|||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||
private readonly DispatcherTimer _consoleFlushTimer;
|
||||
private readonly DispatcherTimer _libraryLayoutTimer;
|
||||
private readonly DispatcherTimer _titleMusicVolumeTimer;
|
||||
|
||||
private GuiSettings _settings = new();
|
||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||
|
|
@ -260,6 +261,15 @@ public partial class MainWindow : Window
|
|||
_libraryLayoutTimer.Stop();
|
||||
UpdateLibraryLayoutMetrics();
|
||||
};
|
||||
_titleMusicVolumeTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(180),
|
||||
};
|
||||
_titleMusicVolumeTimer.Tick += (_, _) =>
|
||||
{
|
||||
_titleMusicVolumeTimer.Stop();
|
||||
_settings.Save();
|
||||
};
|
||||
CarouselAddFolderButton.Click += (_, _) => StartAddFolderFromTile();
|
||||
GameList.AddHandler(PointerPressedEvent, OnCarouselPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
GameList.AddHandler(PointerMovedEvent, OnCarouselPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
|
|
@ -323,6 +333,15 @@ public partial class MainWindow : Window
|
|||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
OptionsTabButton.Click += (_, _) => SetActivePage(1);
|
||||
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
|
||||
TitleMusicMuteButton.Click += (_, _) => TitleMusicToggle.IsChecked = !_settings.PlayTitleMusic;
|
||||
TitleMusicVolumeSlider.ValueChanged += (_, _) =>
|
||||
{
|
||||
_settings.TitleMusicVolume = TitleMusicVolumeSlider.Value / 100.0;
|
||||
_sndPreview.Volume = _settings.TitleMusicVolume;
|
||||
UpdateTitleMusicMuteButton();
|
||||
_titleMusicVolumeTimer.Stop();
|
||||
_titleMusicVolumeTimer.Start();
|
||||
};
|
||||
LibraryPage.SizeChanged += (_, _) => ScheduleLibraryLayoutMetricsUpdate();
|
||||
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryMinimumHeight();
|
||||
MainContent.SizeChanged += (_, _) => ClampEmbeddedConsoleHeight();
|
||||
|
|
@ -342,6 +361,7 @@ public partial class MainWindow : Window
|
|||
{
|
||||
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
|
||||
OnTitleMusicSettingChanged();
|
||||
_settings.Save();
|
||||
};
|
||||
DiscordToggle.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
|
|
@ -1133,6 +1153,8 @@ public partial class MainWindow : Window
|
|||
RefreshUpdateText();
|
||||
UpdateEmptyStateTexts();
|
||||
UpdateLibraryLayoutButton();
|
||||
UpdateTitleMusicMuteButton();
|
||||
AutomationProperties.SetName(TitleMusicVolumeSlider, Localization.Instance.Get("Library.Music.Volume"));
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
|
|
@ -1259,6 +1281,7 @@ public partial class MainWindow : Window
|
|||
Interlocked.Increment(ref _detailLoadGeneration);
|
||||
_consoleFlushTimer.Stop();
|
||||
_libraryLayoutTimer.Stop();
|
||||
_titleMusicVolumeTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
}
|
||||
|
||||
|
|
@ -1426,6 +1449,10 @@ public partial class MainWindow : Window
|
|||
CrashDumpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_CRASH_CAPTURE");
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
||||
TitleMusicVolumeSlider.Value = _settings.TitleMusicVolume * 100.0;
|
||||
_titleMusicVolumeTimer.Stop();
|
||||
_sndPreview.Volume = _settings.TitleMusicVolume;
|
||||
UpdateTitleMusicMuteButton();
|
||||
SetLibraryLayout(string.Equals(_settings.LibraryLayout, "Grid", StringComparison.OrdinalIgnoreCase));
|
||||
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
||||
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
|
||||
|
|
@ -2788,6 +2815,7 @@ public partial class MainWindow : Window
|
|||
|
||||
private void OnTitleMusicSettingChanged()
|
||||
{
|
||||
UpdateTitleMusicMuteButton();
|
||||
if (!_settings.PlayTitleMusic)
|
||||
{
|
||||
_sndPreview.Stop();
|
||||
|
|
@ -2798,6 +2826,17 @@ public partial class MainWindow : Window
|
|||
}
|
||||
}
|
||||
|
||||
private void UpdateTitleMusicMuteButton()
|
||||
{
|
||||
TitleMusicMuteGlyph.Text = !_settings.PlayTitleMusic || _settings.TitleMusicVolume == 0
|
||||
? "volume_off"
|
||||
: _settings.TitleMusicVolume < 0.5 ? "volume_down" : "volume_up";
|
||||
var label = Localization.Instance.Get(
|
||||
_settings.PlayTitleMusic ? "Library.Music.Mute" : "Library.Music.Unmute");
|
||||
ToolTip.SetTip(TitleMusicMuteButton, label);
|
||||
AutomationProperties.SetName(TitleMusicMuteButton, label);
|
||||
}
|
||||
|
||||
/// <summary>Pauses the preview music while the window is minimized.</summary>
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,40 +2,52 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using LibAtrac9;
|
||||
using SDL;
|
||||
using static SDL.SDL3;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Loops a game's sce_sys/snd0.at9 preview music while the game is selected
|
||||
/// in the library, like the console home screen. The ATRAC9 stream is decoded
|
||||
/// to WAV on a background task (vendored LibAtrac9); playback uses winmm's
|
||||
/// PlaySound, so this is Windows-only and a no-op elsewhere.
|
||||
/// to WAV on a background task (vendored LibAtrac9); SDL3 plays the PCM on
|
||||
/// Windows, Linux and macOS.
|
||||
/// </summary>
|
||||
internal sealed class SndPreviewPlayer
|
||||
{
|
||||
private const uint SND_ASYNC = 0x0001;
|
||||
private const uint SND_NODEFAULT = 0x0002;
|
||||
private const uint SND_MEMORY = 0x0004;
|
||||
private const uint SND_LOOP = 0x0008;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private int _generation;
|
||||
private GCHandle _pinnedWav;
|
||||
private bool _playing;
|
||||
private unsafe SDL_AudioStream* _stream;
|
||||
private int _pcmOffset;
|
||||
private int _targetQueuedBytes;
|
||||
private bool _paused;
|
||||
private bool _muted;
|
||||
private double _volume = 1.0;
|
||||
private string? _cachedPath;
|
||||
private byte[]? _cachedWav;
|
||||
|
||||
public unsafe double Volume
|
||||
{
|
||||
set
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_volume = double.IsFinite(value) ? Math.Clamp(value, 0.0, 1.0) : 1.0;
|
||||
if (_stream is not null)
|
||||
{
|
||||
SDL_SetAudioStreamGain(_stream, (float)_volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the music silent while set, even across <see cref="Play"/> and
|
||||
/// <see cref="Resume"/>; the track stays ready so clearing it and
|
||||
/// resuming restarts the music.
|
||||
/// <see cref="Resume"/>; the track stays ready to continue after unmuting.
|
||||
/// </summary>
|
||||
public bool Muted
|
||||
public unsafe bool Muted
|
||||
{
|
||||
get
|
||||
{
|
||||
|
|
@ -46,18 +58,12 @@ internal sealed class SndPreviewPlayer
|
|||
}
|
||||
set
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_muted = value;
|
||||
if (value && _playing)
|
||||
if (value && _stream is not null)
|
||||
{
|
||||
_ = PlaySound(0, 0, 0);
|
||||
_playing = false;
|
||||
SDL_PauseAudioStreamDevice(_stream);
|
||||
_paused = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -67,11 +73,6 @@ internal sealed class SndPreviewPlayer
|
|||
/// <summary>Starts looping the given snd0.at9 after a short debounce.</summary>
|
||||
public void Play(string at9Path)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int generation;
|
||||
lock (_sync)
|
||||
{
|
||||
|
|
@ -119,19 +120,14 @@ internal sealed class SndPreviewPlayer
|
|||
_cachedPath = at9Path;
|
||||
_cachedWav = wav;
|
||||
StopLocked();
|
||||
|
||||
// The WAV image must stay pinned while winmm plays from it.
|
||||
_pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned);
|
||||
if (_muted)
|
||||
try
|
||||
{
|
||||
_paused = true;
|
||||
return;
|
||||
StartLocked(wav);
|
||||
}
|
||||
|
||||
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
|
||||
if (!_playing)
|
||||
catch (Exception error) when (error is DllNotFoundException or EntryPointNotFoundException or InvalidOperationException)
|
||||
{
|
||||
_pinnedWav.Free();
|
||||
Console.Error.WriteLine($"[GUI][WARN] Title music playback unavailable: {error.Message}");
|
||||
StopLocked();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -139,11 +135,6 @@ internal sealed class SndPreviewPlayer
|
|||
|
||||
public void Stop()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_generation++;
|
||||
|
|
@ -152,60 +143,139 @@ internal sealed class SndPreviewPlayer
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Silences playback but keeps the decoded track ready, so
|
||||
/// <see cref="Resume"/> can restart it (winmm cannot truly pause).
|
||||
/// Pauses playback while preserving the current position and queued audio.
|
||||
/// </summary>
|
||||
public void Pause()
|
||||
public unsafe void Pause()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_playing)
|
||||
if (_stream is null || _paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = PlaySound(0, 0, 0);
|
||||
_playing = false;
|
||||
SDL_PauseAudioStreamDevice(_stream);
|
||||
_paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restarts the track silenced by <see cref="Pause"/>.</summary>
|
||||
public void Resume()
|
||||
/// <summary>Continues the track silenced by <see cref="Pause"/>.</summary>
|
||||
public unsafe void Resume()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_paused || _muted || _stream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SDL_ResumeAudioStreamDevice(_stream);
|
||||
_paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void StartLocked(byte[] wav)
|
||||
{
|
||||
if (wav.Length <= 44)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
|
||||
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
|
||||
{
|
||||
if (!_paused || _muted || !_pinnedWav.IsAllocated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
throw new InvalidOperationException("SDL audio initialization failed.");
|
||||
}
|
||||
|
||||
_paused = false;
|
||||
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
|
||||
var spec = new SDL_AudioSpec
|
||||
{
|
||||
format = SDL_AudioFormat.SDL_AUDIO_S16LE,
|
||||
channels = checked((byte)BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22))),
|
||||
freq = BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(24)),
|
||||
};
|
||||
_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, null, IntPtr.Zero);
|
||||
if (_stream is null)
|
||||
{
|
||||
throw new InvalidOperationException("SDL audio stream creation failed.");
|
||||
}
|
||||
|
||||
SDL_SetAudioStreamGain(_stream, (float)_volume);
|
||||
_targetQueuedBytes = spec.freq * spec.channels * sizeof(short) / 4; // 250 ms of buffered audio
|
||||
_pcmOffset = 44;
|
||||
FillStreamLocked(wav);
|
||||
_paused = _muted;
|
||||
if (!_muted && !SDL_ResumeAudioStreamDevice(_stream))
|
||||
{
|
||||
throw new InvalidOperationException("SDL audio stream start failed.");
|
||||
}
|
||||
|
||||
_ = PumpAsync(_generation, wav);
|
||||
}
|
||||
|
||||
private async Task PumpAsync(int generation, byte[] wav)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(40).ConfigureAwait(false);
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FillStreamIfActiveLocked(wav);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopLocked()
|
||||
private unsafe void FillStreamIfActiveLocked(byte[] wav)
|
||||
{
|
||||
_ = PlaySound(0, 0, 0);
|
||||
_playing = false;
|
||||
_paused = false;
|
||||
if (_pinnedWav.IsAllocated)
|
||||
if (_stream is not null && !_paused)
|
||||
{
|
||||
_pinnedWav.Free();
|
||||
FillStreamLocked(wav);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void FillStreamLocked(byte[] wav)
|
||||
{
|
||||
var remaining = _targetQueuedBytes - SDL_GetAudioStreamQueued(_stream);
|
||||
while (remaining > 0)
|
||||
{
|
||||
if (_pcmOffset == wav.Length)
|
||||
{
|
||||
_pcmOffset = 44;
|
||||
}
|
||||
|
||||
var bytes = Math.Min(remaining, wav.Length - _pcmOffset);
|
||||
bytes -= bytes % (BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22)) * sizeof(short));
|
||||
if (bytes == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
fixed (byte* data = &wav[_pcmOffset])
|
||||
{
|
||||
if (!SDL_PutAudioStreamData(_stream, (nint)data, bytes))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_pcmOffset += bytes;
|
||||
remaining -= bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void StopLocked()
|
||||
{
|
||||
if (_stream is not null)
|
||||
{
|
||||
SDL_DestroyAudioStream(_stream);
|
||||
_stream = null;
|
||||
}
|
||||
|
||||
_paused = false;
|
||||
}
|
||||
|
||||
private static readonly Guid Atrac9SubFormat = new("47E142D2-36BA-4D8D-88FC-61654F8C836C");
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -335,8 +405,4 @@ internal sealed class SndPreviewPlayer
|
|||
Encoding.ASCII.GetBytes("data").CopyTo(span[36..]);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(span[40..], sampleCount * channels * 2);
|
||||
}
|
||||
|
||||
[DllImport("winmm.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PlaySound(nint sound, nint module, uint flags);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class TitleMusicVolumeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("{}", 1.0)]
|
||||
[InlineData("{\"TitleMusicVolume\":0.35}", 0.35)]
|
||||
[InlineData("{\"TitleMusicVolume\":-1}", 0.0)]
|
||||
[InlineData("{\"TitleMusicVolume\":4}", 1.0)]
|
||||
public void SavedVolumeIsRestoredAndClamped(string json, double expected)
|
||||
{
|
||||
Assert.Equal(expected, GuiSettings.NormalizeFromJson(json).TitleMusicVolume);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue