feat(sidebar): restore per-tab notification sound picker with preview

This commit is contained in:
2026-06-10 13:26:55 +02:00
parent ac7f74b227
commit 1f2c354471
4 changed files with 258 additions and 19 deletions
+51 -19
View File
@@ -332,7 +332,6 @@ internal class MessageManager : IAsyncDisposable
Store.UpsertMessage(message);
var currentMatches = Plugin.CurrentTab.Matches(message);
uint? notificationSound = null;
foreach (var tab in Plugin.Config.Tabs)
{
var unread = !(
@@ -340,27 +339,19 @@ internal class MessageManager : IAsyncDisposable
);
if (tab.Matches(message))
{
tab.AddMessage(message, unread);
// Per-tab notification sound. Fire once for the first inactive
// tab that wants it, keeping a message matching several
// background tabs from stacking sounds.
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
if (
notificationSound is null
&& TabSoundDecision.ShouldPlay(
Plugin.CurrentTab == tab,
tab.EnableNotificationSound,
Plugin.Config.PlaySounds
)
)
{
notificationSound = tab.NotificationSoundId;
}
}
}
// Deliberate O(2n): the sound pick re-walks the tab list so the selection
// stays pure and SelfTest-able; AddMessage above and playback below keep
// the side effects.
var notificationSound = SelectNotificationSound(
Plugin.Config.Tabs,
Plugin.CurrentTab,
message,
Plugin.Config.PlaySounds
);
if (notificationSound is { } soundId)
{
if (soundId is >= 1 and <= 16)
@@ -388,6 +379,47 @@ internal class MessageManager : IAsyncDisposable
MessageProcessed?.Invoke(message);
}
// Pure: picks the sound id for the first inactive tab that wants one, or null.
// No AddMessage, no store write — those stay in the ProcessMessage loop so this
// is exercisable from the SelfTest without polluting tab state. The "first
// match wins" semantics live here via the running 'picked is null' guard,
// keeping a message matching several background tabs from stacking sounds.
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
internal static uint? SelectNotificationSound(
IEnumerable<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds
)
{
uint? picked = null;
foreach (var tab in tabs)
{
if (!tab.Matches(probe))
continue;
if (
picked is null
&& TabSoundDecision.ShouldPlay(
currentTab == tab,
tab.EnableNotificationSound,
playSounds
)
)
{
picked = tab.NotificationSoundId;
}
}
return picked;
}
// SelfTest hook — same name discipline as InputBar.TestBuildOutgoingForSelfTest.
internal static uint? TestSelectNotificationSoundForSelfTest(
IEnumerable<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds
) => SelectNotificationSound(tabs, currentTab, probe, playSounds);
internal class NameFormatting
{
internal string Before { get; private set; } = string.Empty;
+1
View File
@@ -401,6 +401,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.TellRoutingBuildStep(this),
new SelfTests.TellPillTransparencyStep(this),
new SelfTests.TabRenamePersistStep(this),
new SelfTests.NotificationSoundSelectStep(),
]);
// Re-surface the wizard for existing users when a major UX
@@ -0,0 +1,106 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.Util;
namespace HellionChat.SelfTests;
// B3-3: notification-sound selection. Drives the pure SelectNotificationSound
// (the exact pick logic ProcessMessage runs per message) through its SelfTest
// wrapper with local synthetic tabs — Plugin.Config.Tabs is never touched, so
// no real tab gains messages or unread state. The audible preview button is
// smoke-only and deliberately not exercised here.
internal sealed class NotificationSoundSelectStep : ISelfTestStep
{
public string Name => "Hellion Chat - Notification sound selection";
public SelfTestStepResult RunStep()
{
// Probe: a plain Say line, built the FakeMessage way (InputPreview /
// AutoTellTabsService pattern). Source 0 short-circuits the source
// filter in Message.Matches, so only the ChatType key decides a match.
var ss = new SeStringBuilder().AddText("probe").Build();
var chunks = ChunkUtil.ToChunks(ss, ChunkSource.Content, ChatType.Say).ToList();
var probe = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0));
// The current tab wants a sound too — it must lose ONLY because it is
// current, so a broken is-active exclusion yields 1 instead of 7 here.
var currentTab = MakeSayTab(enableSound: true, soundId: 1);
var inactiveWanting = MakeSayTab(enableSound: true, soundId: 7);
// (a) the inactive tab that wants a sound wins.
var picked = MessageManager.TestSelectNotificationSoundForSelfTest(
[currentTab, inactiveWanting],
currentTab,
probe,
playSounds: true
);
if (picked != 7)
{
ImGui.Text($"Expected sound 7 from inactive tab, got {picked?.ToString() ?? "null"}");
return SelfTestStepResult.Fail;
}
// (b) first match wins: a later qualifying tab must not override.
var second = MakeSayTab(enableSound: true, soundId: 9);
picked = MessageManager.TestSelectNotificationSoundForSelfTest(
[currentTab, inactiveWanting, second],
currentTab,
probe,
playSounds: true
);
if (picked != 7)
{
ImGui.Text($"First-match guard broken: expected 7, got {picked?.ToString() ?? "null"}");
return SelfTestStepResult.Fail;
}
// (c) the global sound master mutes everything.
picked = MessageManager.TestSelectNotificationSoundForSelfTest(
[currentTab, inactiveWanting],
currentTab,
probe,
playSounds: false
);
if (picked is not null)
{
ImGui.Text($"PlaySounds=false must return null, got {picked}");
return SelfTestStepResult.Fail;
}
// (d) negative: a tab without the Say channel never matches the probe.
var nonMatching = new Tab { EnableNotificationSound = true, NotificationSoundId = 7 };
picked = MessageManager.TestSelectNotificationSoundForSelfTest(
[currentTab, nonMatching],
currentTab,
probe,
playSounds: true
);
if (picked is not null)
{
ImGui.Text($"Non-matching tab must not pick a sound, got {picked}");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
// Local synthetic tab matching Say, the way TabsUtil presets build their
// channel maps. Non-temp and without TellTarget, so Tab.Matches stays on
// the pure channel path instead of routing through MatchesSender.
private static Tab MakeSayTab(bool enableSound, uint soundId) =>
new()
{
Name = "selftest-sound",
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
EnableNotificationSound = enableSound,
NotificationSoundId = soundId,
};
public void CleanUp() { }
}
+100
View File
@@ -1,5 +1,9 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using FFXIVClientStructs.FFXIV.Client.UI;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
@@ -26,12 +30,108 @@ internal static class TabContextMenu
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name))
Plugin.Instance.SaveConfig();
// Per-tab notification sound (B3-3). The checkbox gates the picker so
// tabs that never want a sound keep the popup short.
if (
ImGui.Checkbox(
HellionStrings.Tabs_NotificationSound_Enable_Name,
ref tab.EnableNotificationSound
)
)
Plugin.Instance.SaveConfig();
ImGuiUtil.HelpMarker(HellionStrings.Tabs_NotificationSound_Description);
if (tab.EnableNotificationSound)
DrawSoundPicker(tab);
if (ImGui.MenuItem("Pop Out"))
pool.TryOpen(tab);
ImGui.EndPopup();
}
// Sound picker: 16 numbered game sounds, a separator, then the 3 bundled
// Hellion clips stored as ids 17-19 (1.5.6 parity order). The collapsed
// preview reuses the entry label scheme so the current pick reads the same
// open or closed.
private static void DrawSoundPicker(Tab tab)
{
var preview =
tab.NotificationSoundId <= 16
? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}"
: $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}";
using (
var combo = ImGuiUtil.BeginComboVertical(
HellionStrings.Tabs_NotificationSound_Option,
preview
)
)
{
if (combo.Success)
{
for (uint s = 1; s <= 16; s++)
{
if (
ImGui.Selectable(
$"{HellionStrings.Tabs_NotificationSound_Option} {s}",
tab.NotificationSoundId == s
)
)
{
tab.NotificationSoundId = s;
Plugin.Instance.SaveConfig();
}
}
ImGui.Separator();
for (uint n = 1; n <= 3; n++)
{
var customId = 16 + n;
if (
ImGui.Selectable(
$"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}",
tab.NotificationSoundId == customId
)
)
{
tab.NotificationSoundId = customId;
Plugin.Instance.SaveConfig();
}
}
}
}
if (
ImGuiUtil.IconButton(
FontAwesomeIcon.Play,
"tab-sound-preview",
HellionStrings.Tabs_NotificationSound_Preview
)
)
PreviewSound(tab.NotificationSoundId);
}
// Preview: 1-16 are game UI sounds (must hit the framework thread); 17+ are
// custom NAudio clips (own playback thread). Open range >= 17 (not 17-19); the
// 3-clip ceiling is guarded inside CustomAudioPlayer.
private static void PreviewSound(uint id)
{
if (id is >= 1 and <= 16)
{
Plugin.Framework.RunOnFrameworkThread(() =>
{
unsafe
{
UIGlobals.PlaySoundEffect(id);
}
});
}
else if (id >= 17)
{
Plugin.Instance.CustomAudioPlayer.Play((int)id - 16, Plugin.Config.CustomSoundVolume);
}
}
// Factored out so the SelfTest drives the real rename path, not a field poke.
// Returns true when the name actually changed (gates the SaveConfig write).
internal static bool ApplyTabRename(Tab tab, string newName)