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);
|
Store.UpsertMessage(message);
|
||||||
|
|
||||||
var currentMatches = Plugin.CurrentTab.Matches(message);
|
var currentMatches = Plugin.CurrentTab.Matches(message);
|
||||||
uint? notificationSound = null;
|
|
||||||
foreach (var tab in Plugin.Config.Tabs)
|
foreach (var tab in Plugin.Config.Tabs)
|
||||||
{
|
{
|
||||||
var unread = !(
|
var unread = !(
|
||||||
@@ -340,26 +339,18 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (tab.Matches(message))
|
if (tab.Matches(message))
|
||||||
{
|
|
||||||
tab.AddMessage(message, unread);
|
tab.AddMessage(message, unread);
|
||||||
|
}
|
||||||
|
|
||||||
// Per-tab notification sound. Fire once for the first inactive
|
// Deliberate O(2n): the sound pick re-walks the tab list so the selection
|
||||||
// tab that wants it, keeping a message matching several
|
// stays pure and SelfTest-able; AddMessage above and playback below keep
|
||||||
// background tabs from stacking sounds.
|
// the side effects.
|
||||||
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
|
var notificationSound = SelectNotificationSound(
|
||||||
if (
|
Plugin.Config.Tabs,
|
||||||
notificationSound is null
|
Plugin.CurrentTab,
|
||||||
&& TabSoundDecision.ShouldPlay(
|
message,
|
||||||
Plugin.CurrentTab == tab,
|
|
||||||
tab.EnableNotificationSound,
|
|
||||||
Plugin.Config.PlaySounds
|
Plugin.Config.PlaySounds
|
||||||
)
|
);
|
||||||
)
|
|
||||||
{
|
|
||||||
notificationSound = tab.NotificationSoundId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationSound is { } soundId)
|
if (notificationSound is { } soundId)
|
||||||
{
|
{
|
||||||
@@ -388,6 +379,47 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
MessageProcessed?.Invoke(message);
|
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 class NameFormatting
|
||||||
{
|
{
|
||||||
internal string Before { get; private set; } = string.Empty;
|
internal string Before { get; private set; } = string.Empty;
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
new SelfTests.TellRoutingBuildStep(this),
|
new SelfTests.TellRoutingBuildStep(this),
|
||||||
new SelfTests.TellPillTransparencyStep(this),
|
new SelfTests.TellPillTransparencyStep(this),
|
||||||
new SelfTests.TabRenamePersistStep(this),
|
new SelfTests.TabRenamePersistStep(this),
|
||||||
|
new SelfTests.NotificationSoundSelectStep(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Re-surface the wizard for existing users when a major UX
|
// 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.Bindings.ImGui;
|
||||||
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
|
using HellionChat.Resources;
|
||||||
|
using HellionChat.Util;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Components;
|
namespace HellionChat.Ui.Components;
|
||||||
|
|
||||||
@@ -26,12 +30,108 @@ internal static class TabContextMenu
|
|||||||
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name))
|
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name))
|
||||||
Plugin.Instance.SaveConfig();
|
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"))
|
if (ImGui.MenuItem("Pop Out"))
|
||||||
pool.TryOpen(tab);
|
pool.TryOpen(tab);
|
||||||
|
|
||||||
ImGui.EndPopup();
|
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.
|
// 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).
|
// Returns true when the name actually changed (gates the SaveConfig write).
|
||||||
internal static bool ApplyTabRename(Tab tab, string newName)
|
internal static bool ApplyTabRename(Tab tab, string newName)
|
||||||
|
|||||||
Reference in New Issue
Block a user