feat(sidebar): restore per-tab notification sound picker with preview
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user