Bump to v3.1.0: new audio cues (save, profile-switch), per-profile custom cue paths, redesigned tray menu
Two new cues join the existing connect / disconnect / record-start /
record-stop set:
* profile-save cue (sounds\save.wav by default) fires in
SaveProfileTo after a successful Save or Save As. Honours the
Profile.EnableSaveCue per-profile flag.
* profile-switch cue (sounds\profile.wav by default) fires in the
MainForm Shown handler after a profile finishes loading - covers
both startup-with-profile and mid-session profile switches.
Honours the Profile.EnableProfileSwitchCue flag. Because cue
loading runs AFTER settings.ApplyProfile in the ctor (line 542),
the profile being entered determines which sound plays - not the
one being left, exactly as Ed asked.
The audio cue UI in PreferencesDialog stays as a CheckedListBox (up /
down navigates, Space toggles) with TWO action buttons below that
operate on whichever cue is selected:
* Play [cue name] (Alt+P) - previews via SoundPlayer.Play on the
resolved path (custom override if set, default in sounds\
otherwise). Independent of the tick state.
* Browse for [cue name]... (Alt+B) - opens a WAV picker. If the
user picks a file inside RemSound's own sounds\ folder, it's
treated as "use default" and the override is cleared - avoids
pinning the user to a specific shipped default that a future
release might replace. Right-click "Use default sound" reverts.
Custom cue paths AND enable flags are per-profile. Lives on
Profile.CustomCuePaths (Dictionary<string,string>) and the per-cue
EnableXxxCue bool? properties. Cache mirror in
RemSoundSettingsStore.Settings.
All default WAVs moved from the install-root flat layout into a
sounds\ subfolder (csproj Content rules updated). save.wav and
profile.wav are bundled defaults.
Tray menu rewritten (MainFormTrayController) per Ed's spec:
* Show RemSound (W) - now uses Win32 SetForegroundWindow after the
standard Activate() because WinForms Activate is blocked by the
foreground-lock when invoked from a tray-menu click, which left
screen-reader users having to Alt+Tab to reach the restored
window.
* Enable sending (S) / Enable receiving (R) - tickable, reflect
current state, TOGGLE rather than always-on.
* Profiles (P) - submenu populated from AppConfig.RecentProfiles
with the same &1..&5 mnemonics the File menu uses. Pre-populated
once at construction so WinForms recognises it as a submenu and
fires DropDownOpening - originally I relied entirely on the
open event, which the framework skipped for items with no
DropDownItems, producing the "Profiles does nothing" bug.
* Exit (X).
Tray tooltip now built dynamically from snapshot tick (1 Hz):
"RemSound - [recording for MM:SS,] N peer(s), sending (lane),
receiving (lane)". Recording timer only included while
RecordingController.IsRecording is true (added
RecordingStartedUtc accessor for the elapsed calculation). Lane is
derived from which device-list ticks are active, not just the audio-
mode setting, so a BothIndependent user with only WASAPI inputs ticked
honestly reads as "sending (WASAPI)".
Fixes:
* Initial tooltip "RemSound" produced a "RemSound RemSound" read on
NVDA because the process name and tooltip matched. Set to
"RemSound - starting up" so the duplicate disappears.
* Recent profile menu items no longer carry a "Recent profile N:"
AccessibleName prefix in either the tray submenu or the File
menu's Recent profiles - now just the profile name. Number-key
mnemonics (&1..&5) untouched.
Manual (readme.html) updated: new section 17 "Audio cue sounds"
documents all six cues, the Play/Browse buttons, the right-click
"Use default sound", and the per-profile semantics. Sections 17-21
renumbered to 18-22. New "System tray icon and its menu" subsection
inside section 4 documents the redesigned right-click menu and the
hover tooltip. MANUAL.md regenerated via sync-manual.py. About box
gets a v3.1 block at the top. RELEASE_NOTES.md fully rewritten for
v3.1.
No wire format change - v3.1 talks to other v3.0.x machines exactly
as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0189b12668
commit
70c2669d70
@@ -20,6 +20,83 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.1
|
||||
|
||||
Two big rounds of work on audio cues and the system
|
||||
tray, plus a handful of smaller fixes.
|
||||
|
||||
New audio cue sounds:
|
||||
* Profile saved. Plays a short cue whenever a
|
||||
profile is saved (Ctrl+S or File menu Save as).
|
||||
Gives you an audible "yes, that took" so you
|
||||
don't have to look at the screen.
|
||||
* Profile switched. Plays a short cue whenever a
|
||||
profile finishes loading - at startup and after
|
||||
you pick a different profile. It plays the new
|
||||
profile's cue, not the old one's, so if you
|
||||
give each profile a different switched-to
|
||||
sound you can tell at a glance which profile
|
||||
you're now on.
|
||||
|
||||
Per-profile custom cue sounds:
|
||||
* The Audio cue sounds list in Preferences now has
|
||||
six entries (Connect, Disconnect, Recording
|
||||
start, Recording stop, Profile saved, Profile
|
||||
switched), each with a tick to enable or
|
||||
silence it.
|
||||
* Two new buttons sit under the list. Play
|
||||
previews the currently-highlighted cue through
|
||||
your default Windows output. Browse opens a
|
||||
file picker so you can replace the default
|
||||
sound with any WAV file from your own disk.
|
||||
Right-clicking Browse offers "Use default
|
||||
sound" to undo the swap.
|
||||
* Both the tick states AND the custom sound
|
||||
choices are saved with the active profile, so
|
||||
different profiles can carry different cue
|
||||
palettes. A "quiet listening" profile can have
|
||||
all cues off; a "studio" profile can use a
|
||||
distinct set of sounds.
|
||||
* The default WAV files have moved from sitting
|
||||
loose next to RemSound.exe into a new sounds
|
||||
subfolder, keeping the install folder tidier.
|
||||
|
||||
System tray icon redesigned:
|
||||
* Hovering shows a live summary - peer count,
|
||||
send / receive mode (WASAPI, ASIO, or both),
|
||||
and how long any current recording has been
|
||||
running. Refreshes every second.
|
||||
* Right-click menu reworked. The items are now
|
||||
Show RemSound (W), Enable sending (S, ticks
|
||||
reflect state), Enable receiving (R, same),
|
||||
Profiles (P, submenu of your recent profiles
|
||||
with number-key shortcuts), and Exit (X).
|
||||
* Show RemSound now reliably brings the window
|
||||
forward and focuses it, fixing a case where
|
||||
screen reader users had to Alt+Tab to actually
|
||||
reach the restored window.
|
||||
* Enable sending and Enable receiving toggle the
|
||||
state instead of always switching it on, so
|
||||
you can use them to turn off too.
|
||||
* The Profiles submenu lets you switch profiles
|
||||
from the tray without re-opening the main
|
||||
window. Number keys 1..5 jump straight to a
|
||||
slot.
|
||||
|
||||
Bug fixes:
|
||||
* The tray icon's initial hover text was reading
|
||||
as "RemSound RemSound" on screen readers
|
||||
because the tooltip text matched the process
|
||||
name. Now reads cleanly as "RemSound - starting
|
||||
up" at first, then the live state takes over.
|
||||
* Recent profiles in both menus no longer
|
||||
announce a "Recent profile N:" prefix - they
|
||||
just read the profile name. The number-key
|
||||
shortcuts still work.
|
||||
|
||||
No wire format change. v3.1 talks to other v3.0.x
|
||||
machines exactly as v3.0 / v3.0.1 / v3.0.2 did.
|
||||
|
||||
RemSound v3.0.2
|
||||
|
||||
Hot-fix for a slow memory leak in the receive side. If
|
||||
|
||||
+223
-16
@@ -321,6 +321,11 @@ public sealed class MainForm : Form
|
||||
// anything the recorder is doing.
|
||||
private System.Media.SoundPlayer? recordStartSound;
|
||||
private System.Media.SoundPlayer? recordStopSound;
|
||||
// Profile-save and profile-switch cues, added 2026-05-28 alongside the move of all
|
||||
// default WAVs into a sounds\ subfolder. Save fires after a successful File → Save /
|
||||
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
||||
private System.Media.SoundPlayer? saveSound;
|
||||
private System.Media.SoundPlayer? profileSwitchSound;
|
||||
// Labels for the three send/receive device lists, captured at layout time so they can be
|
||||
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
|
||||
// (driver channel pairs). null until BuildLayout has run.
|
||||
@@ -606,9 +611,18 @@ public sealed class MainForm : Form
|
||||
hotkeyController.OnHotkeyChanged = MarkProfileDirty;
|
||||
trayController = new MainFormTrayController(
|
||||
this,
|
||||
() => sendMyAudioCheckbox.Checked = true,
|
||||
() => receiveAudioCheckbox.Checked = true,
|
||||
Close);
|
||||
// getSending / toggleSending — the tray's "Enable sending" checkable item reads
|
||||
// and toggles the main-window send checkbox. Toggle (not set-to-true) so right-
|
||||
// clicking it twice doesn't get the user stuck on. 2026-05-28 redesign.
|
||||
getSending: () => sendMyAudioCheckbox.Checked,
|
||||
toggleSending: () => sendMyAudioCheckbox.Checked = !sendMyAudioCheckbox.Checked,
|
||||
getReceiving: () => receiveAudioCheckbox.Checked,
|
||||
toggleReceiving: () => receiveAudioCheckbox.Checked = !receiveAudioCheckbox.Checked,
|
||||
// Profiles submenu — list of recent profile file paths read live from AppConfig
|
||||
// every time the submenu opens, so newly-loaded profiles appear immediately.
|
||||
getRecentProfilePaths: () => AppConfig.Load().RecentProfiles,
|
||||
switchToProfile: path => SwitchToRecentProfile(path),
|
||||
exit: Close);
|
||||
|
||||
recordingController = new RecordingController(
|
||||
sender,
|
||||
@@ -833,12 +847,16 @@ public sealed class MainForm : Form
|
||||
sender.Diagnostic = msg => logFile.Event($"sender: {msg}");
|
||||
receiver.Diagnostic = msg => logFile.Event($"receiver: {msg}");
|
||||
|
||||
// Pre-load peer-state cue sounds so the first playback isn't delayed by file I/O.
|
||||
// Files are deployed alongside the .exe (see RemSound.App.csproj Content rules).
|
||||
TryLoadCueSound("connect.wav", out connectSound);
|
||||
TryLoadCueSound("disconnect.wav", out disconnectSound);
|
||||
TryLoadCueSound("record start.wav", out recordStartSound);
|
||||
TryLoadCueSound("record stop.wav", out recordStopSound);
|
||||
// Pre-load all cue sounds so the first playback isn't delayed by file I/O. Default
|
||||
// WAVs are deployed to a sounds\ subfolder under RemSound.exe (see RemSound.App
|
||||
// .csproj Content rules); the per-cue custom-path overrides in AppConfig.CustomCuePaths
|
||||
// are honoured by TryLoadCueSound when set.
|
||||
TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound);
|
||||
TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound);
|
||||
TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound);
|
||||
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||
|
||||
LoadAudioDevices();
|
||||
// Apply persisted ASIO mode from settings — switches sender/receiver backends so the
|
||||
@@ -1030,6 +1048,17 @@ public sealed class MainForm : Form
|
||||
// blank-template case (no pendingProfile) we schedule it here.
|
||||
if (pendingProfile is null) ScheduleBaselineCapture();
|
||||
ApplyPendingProfileToControls();
|
||||
// Profile-switch cue (2026-05-28): fires once after the profile finishes loading
|
||||
// into the UI. Covers BOTH startup (user picks a profile from the picker) and
|
||||
// mid-session switch (user picks a different profile from the menu — Program.Main
|
||||
// re-creates MainForm under the new profile). Skipped when the user is on the
|
||||
// blank template, where there's no profile to announce. Honours the per-profile
|
||||
// EnableProfileSwitchCue flag set in Preferences.
|
||||
if (pendingProfile is not null
|
||||
&& settings.LoadEnableProfileSwitchCue())
|
||||
{
|
||||
profileSwitchSound?.Play();
|
||||
}
|
||||
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
|
||||
// profile or the blank template.
|
||||
UpdateProfileButtonsVisibility();
|
||||
@@ -1393,9 +1422,13 @@ public sealed class MainForm : Form
|
||||
if (string.IsNullOrWhiteSpace(path)) continue;
|
||||
if (!File.Exists(path)) continue; // skip missing files; keep in storage in case they reappear
|
||||
var title = Path.GetFileNameWithoutExtension(path);
|
||||
// Visible Text has the &1..&5 mnemonic for number-key access; AccessibleName is
|
||||
// just the profile name so NVDA reads the menu item naturally rather than
|
||||
// prefixing every entry with "Recent profile N:" (which was the original cut
|
||||
// and Ed flagged it as noisy / unwanted).
|
||||
var item = new ToolStripMenuItem($"&{slot} {title}")
|
||||
{
|
||||
AccessibleName = $"Recent profile {slot}: {title}",
|
||||
AccessibleName = title,
|
||||
// Stash the path on the menu item so the click handler doesn't depend on
|
||||
// closure capture of the loop variable.
|
||||
Tag = path,
|
||||
@@ -1795,6 +1828,11 @@ public sealed class MainForm : Form
|
||||
unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler);
|
||||
dialog.ShowDialog(this);
|
||||
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
|
||||
// The Preferences dialog includes per-cue Browse buttons that can change custom
|
||||
// WAV paths in AppConfig.CustomCuePaths. Reload the cached SoundPlayer instances
|
||||
// here unconditionally — cheap, only six small files, and guarantees the next
|
||||
// play uses whatever the user just picked without waiting for the next launch.
|
||||
ReloadAllCueSounds();
|
||||
}
|
||||
|
||||
/// <summary>User pressed "Check for updates" (Help menu or Preferences button). Always
|
||||
@@ -4169,6 +4207,10 @@ public sealed class MainForm : Form
|
||||
// serialised on the network-thread lock inside the receiver, so doing it from the UI
|
||||
// tick is safe.
|
||||
receiver.PruneIdleSessions();
|
||||
// Refresh the tray icon's hover tooltip so it reflects the current peer count and
|
||||
// send / receive routing (WASAPI / ASIO / both). 1 Hz cadence is fine — the user is
|
||||
// hovering, not staring at a counter — and BuildTrayTooltip is allocation-cheap.
|
||||
trayController.SetTooltip(BuildTrayTooltip());
|
||||
// Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main)
|
||||
// explicitly avoids gen2 collections to keep audio scheduling smooth — but that same
|
||||
// suppression means finalizers for IDisposable wrappers that didn't get explicit
|
||||
@@ -4892,6 +4934,11 @@ public sealed class MainForm : Form
|
||||
{
|
||||
SaveCurrentStateToProfileFile(title);
|
||||
AppendLogEntry($"profile saved: \"{title}\"");
|
||||
// Save cue (2026-05-28): fires after any successful save — Save AND Save As, since
|
||||
// both routes funnel through this single method. Honours the EnableSaveCue per-
|
||||
// profile flag; the cue is silent if the user has unticked it in Preferences or if
|
||||
// sounds\save.wav doesn't exist and no custom override has been set.
|
||||
if (settings.LoadEnableSaveCue()) saveSound?.Play();
|
||||
// Refresh the unsaved-changes baseline so this saved state becomes the new
|
||||
// "no changes" reference. The Title field changes on save-as, so the next
|
||||
// diff comparison must use the new state as baseline, not the pre-save one.
|
||||
@@ -5153,16 +5200,162 @@ public sealed class MainForm : Form
|
||||
// tab — see BuildProfilesPrefsTab + SwitchSelectedProfile / RenameSelectedProfile /
|
||||
// DeleteSelectedProfile.
|
||||
|
||||
private void TryLoadCueSound(string fileName, out System.Media.SoundPlayer? player)
|
||||
/// <summary>Builds the live tooltip shown over the system-tray icon — sums up peer count
|
||||
/// and send / receive routing into a single readable line. Kept under the 127-character
|
||||
/// NotifyIcon limit by construction; the tray controller truncates with an ellipsis as a
|
||||
/// belt-and-braces if a future addition ever pushes it over.
|
||||
///
|
||||
/// Examples:
|
||||
/// * RemSound — not connected
|
||||
/// * RemSound — recording for 2:34 — not connected
|
||||
/// * RemSound — 2 peers, sending (WASAPI), receiving (WASAPI)
|
||||
/// * RemSound — recording for 1:23:45, 2 peers, sending (WASAPI), receiving (WASAPI)
|
||||
/// </summary>
|
||||
private string BuildTrayTooltip()
|
||||
{
|
||||
// Healthy-peer count. Heartbeats define "connected" — a peer ticked in the list but
|
||||
// never reachable doesn't count, because the user cares about who they can actually
|
||||
// talk to right now, not who they intend to.
|
||||
var healthyPeers = 0;
|
||||
if (heartbeatService is not null)
|
||||
{
|
||||
foreach (var ph in heartbeatService.GetAllPeerHealth())
|
||||
{
|
||||
if (ph.State == PeerHealthState.Healthy) healthyPeers++;
|
||||
}
|
||||
}
|
||||
// Recording timer — only included when a recording is actually running. Slots in
|
||||
// right after the "RemSound" leader as Ed asked, so it reads as a status on the
|
||||
// app itself rather than a property of the peer list.
|
||||
string? recordingPart = null;
|
||||
if (recordingController.IsRecording && recordingController.RecordingStartedUtc is { } startedUtc)
|
||||
{
|
||||
recordingPart = $"recording for {FormatRecordingElapsed(DateTime.UtcNow - startedUtc)}";
|
||||
}
|
||||
if (healthyPeers == 0 && !sendMyAudioCheckbox.Checked && !receiveAudioCheckbox.Checked)
|
||||
{
|
||||
return recordingPart is null
|
||||
? "RemSound — not connected"
|
||||
: $"RemSound — {recordingPart} — not connected";
|
||||
}
|
||||
|
||||
var parts = new List<string> { "RemSound" };
|
||||
if (recordingPart is not null) parts.Add(recordingPart);
|
||||
var peerText = healthyPeers switch
|
||||
{
|
||||
0 => "no peers",
|
||||
1 => "1 peer",
|
||||
_ => $"{healthyPeers} peers",
|
||||
};
|
||||
parts.Add(peerText);
|
||||
|
||||
// Direction lines — only added when the corresponding direction is actually on. The
|
||||
// lane label (WASAPI / ASIO / WASAPI + ASIO) is derived from which device-list ticks
|
||||
// are active, NOT from the audio-mode setting alone: in BothIndependent a user can
|
||||
// still have ticked WASAPI inputs only, in which case the tray should honestly say
|
||||
// "WASAPI" rather than "WASAPI + ASIO".
|
||||
if (sendMyAudioCheckbox.Checked)
|
||||
{
|
||||
var hasWasapiSend = AnyChecked(sendInputDevicesList) || AnyChecked(sendOutputDevicesList);
|
||||
var hasAsioSend = AnyChecked(asioSendDevicesList);
|
||||
parts.Add($"sending ({DescribeLanes(hasWasapiSend, hasAsioSend)})");
|
||||
}
|
||||
if (receiveAudioCheckbox.Checked)
|
||||
{
|
||||
var hasWasapiReceive = AnyChecked(receiveOutputDevicesList);
|
||||
var hasAsioReceive = AnyChecked(asioReceiveOutputDevicesList);
|
||||
parts.Add($"receiving ({DescribeLanes(hasWasapiReceive, hasAsioReceive)})");
|
||||
}
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Convenience for BuildTrayTooltip — true if the given CheckedListBox has at
|
||||
/// least one ticked item. Defensive against null lists (the layout builders run async on
|
||||
/// startup, so the tooltip refresh CAN fire one tick before they exist).</summary>
|
||||
private static bool AnyChecked(CheckedListBox? list)
|
||||
{
|
||||
if (list is null) return false;
|
||||
return list.CheckedItems.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Compact duration formatter for the tray tooltip's "recording for X" segment.
|
||||
/// Under an hour shows MM:SS; from an hour on it shows H:MM:SS — the same shape Windows
|
||||
/// uses for media-player elapsed-time displays, so it reads naturally to people who
|
||||
/// don't otherwise know it's a custom format.</summary>
|
||||
private static string FormatRecordingElapsed(TimeSpan elapsed)
|
||||
{
|
||||
// Negative elapsed (clock skew across a sleep cycle) gets clamped to zero — better
|
||||
// than displaying "-00:01" mid-tooltip.
|
||||
if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero;
|
||||
return elapsed.TotalHours >= 1
|
||||
? $"{(int)elapsed.TotalHours}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
|
||||
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
|
||||
}
|
||||
|
||||
/// <summary>Pretty-print "WASAPI", "ASIO", "WASAPI + ASIO", or "no devices" depending
|
||||
/// on which of the two flags are set. "no devices" covers the awkward case where the
|
||||
/// user has the send/receive checkbox on but hasn't ticked anything for the engine to
|
||||
/// chew on — better to say so in the tooltip than imply silent activity.</summary>
|
||||
private static string DescribeLanes(bool wasapi, bool asio) =>
|
||||
(wasapi, asio) switch
|
||||
{
|
||||
(true, true) => "WASAPI + ASIO",
|
||||
(true, false) => "WASAPI",
|
||||
(false, true) => "ASIO",
|
||||
_ => "no devices",
|
||||
};
|
||||
|
||||
/// <summary>Well-known cue identifiers used as keys into
|
||||
/// <see cref="AppConfig.CustomCuePaths"/>. Stable strings — don't rename without writing
|
||||
/// a migration, because users' Preferences-set custom paths are stored under these keys
|
||||
/// in <c>remsound.config.json</c>. Centralised here so the Preferences dialog and the
|
||||
/// MainForm load path agree on the spellings.</summary>
|
||||
internal static class CueId
|
||||
{
|
||||
public const string Connect = "connect";
|
||||
public const string Disconnect = "disconnect";
|
||||
public const string RecordStart = "record-start";
|
||||
public const string RecordStop = "record-stop";
|
||||
public const string Save = "save";
|
||||
public const string ProfileSwitch = "profile-switch";
|
||||
}
|
||||
|
||||
/// <summary>Load one cue sound. Resolution order:
|
||||
/// (1) if the active profile has a custom path for <paramref name="cueId"/> AND the
|
||||
/// referenced file exists, use that — the user-supplied override.
|
||||
/// (2) otherwise the default WAV in <c>sounds\</c> next to RemSound.exe, named
|
||||
/// <paramref name="defaultFileName"/>.
|
||||
/// (3) otherwise null — the cue silently doesn't play. New cues without a shipped
|
||||
/// default WAV (e.g. save.wav and profile.wav before the project owner supplies
|
||||
/// them) land here and the rest of the app keeps working.
|
||||
///
|
||||
/// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so
|
||||
/// each profile can carry its own cue palette. The settings cache mirrors the active
|
||||
/// profile's CustomCuePaths dictionary and is the runtime source of truth.</summary>
|
||||
private void TryLoadCueSound(string cueId, string defaultFileName, out System.Media.SoundPlayer? player)
|
||||
{
|
||||
player = null;
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
string? path = null;
|
||||
var customPath = settings.LoadCustomCuePath(cueId);
|
||||
if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath))
|
||||
{
|
||||
logFile.Event($"cue sound missing: {fileName} (looked at {path})");
|
||||
return;
|
||||
path = customPath;
|
||||
logFile.Event($"cue sound '{cueId}': using custom path {customPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
|
||||
if (File.Exists(defaultPath))
|
||||
{
|
||||
path = defaultPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
logFile.Event($"cue sound '{cueId}': default missing ({defaultPath}) and no custom override set — cue will be silent");
|
||||
return;
|
||||
}
|
||||
}
|
||||
var sp = new System.Media.SoundPlayer(path);
|
||||
sp.LoadAsync();
|
||||
@@ -5170,10 +5363,24 @@ public sealed class MainForm : Form
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logFile.Event($"cue sound load failed for {fileName}: {ex.GetType().Name}: {ex.Message}");
|
||||
logFile.Event($"cue sound load failed for '{cueId}': {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-load all cue sounds. Called by PreferencesDialog after the user picks a
|
||||
/// new custom WAV for any cue — re-runs <see cref="TryLoadCueSound"/> for the lot so
|
||||
/// the cached SoundPlayer instances point at the right file from the next play onward.
|
||||
/// </summary>
|
||||
public void ReloadAllCueSounds()
|
||||
{
|
||||
TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound);
|
||||
TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound);
|
||||
TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound);
|
||||
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares current peer-health states to the last-seen states and plays a connect /
|
||||
/// disconnect cue on the relevant transitions. Driven from the 1 Hz snapshot tick. Rules:
|
||||
|
||||
@@ -1,25 +1,153 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the system-tray icon RemSound shows while it's minimised. The icon's right-click
|
||||
/// menu provides quick access to the most common actions without restoring the window —
|
||||
/// useful for users who park RemSound in the tray and never look at the main window again.
|
||||
///
|
||||
/// Menu (2026-05-28 rewrite — the original four-item menu was just Show / Sending /
|
||||
/// Receiving / Exit, with no recent-profiles entry and no live state on the checkable items):
|
||||
///
|
||||
/// * Show RemSound (Alt+W) — restore the main window
|
||||
/// * Enable sending (Alt+S) — checkable, reflects sendMyAudioCheckbox state
|
||||
/// * Enable receiving (Alt+R) — checkable, reflects receiveAudioCheckbox state
|
||||
/// * Profiles (Alt+P) — submenu of AppConfig.RecentProfiles, picking one
|
||||
/// switches the active profile
|
||||
/// * Exit (Alt+X) — close the app
|
||||
///
|
||||
/// Tooltip (NotifyIcon.Text): set dynamically from MainForm's snapshot tick via
|
||||
/// <see cref="SetTooltip"/>. Default is "RemSound" until the first refresh. Capped at
|
||||
/// 127 characters because Windows truncates anything beyond that.
|
||||
/// </summary>
|
||||
internal sealed class MainFormTrayController : IDisposable
|
||||
{
|
||||
/// <summary>Maximum NotifyIcon.Text length on Windows 10+. Windows truncates anything
|
||||
/// longer; truncating ourselves means the tooltip ends with our own "..." rather than
|
||||
/// being chopped mid-word.</summary>
|
||||
private const int MaxTooltipLength = 127;
|
||||
|
||||
private readonly Form owner;
|
||||
private readonly NotifyIcon trayIcon = new();
|
||||
|
||||
public MainFormTrayController(Form owner, Action enableSending, Action enableReceiving, Action exit)
|
||||
private readonly Func<bool> getSending;
|
||||
private readonly Action toggleSending;
|
||||
private readonly Func<bool> getReceiving;
|
||||
private readonly Action toggleReceiving;
|
||||
private readonly Func<IReadOnlyList<string>> getRecentProfilePaths;
|
||||
private readonly Action<string> switchToProfile;
|
||||
private readonly Action exit;
|
||||
|
||||
private readonly ToolStripMenuItem sendingItem;
|
||||
private readonly ToolStripMenuItem receivingItem;
|
||||
private readonly ToolStripMenuItem profilesItem;
|
||||
|
||||
public MainFormTrayController(
|
||||
Form owner,
|
||||
Func<bool> getSending,
|
||||
Action toggleSending,
|
||||
Func<bool> getReceiving,
|
||||
Action toggleReceiving,
|
||||
Func<IReadOnlyList<string>> getRecentProfilePaths,
|
||||
Action<string> switchToProfile,
|
||||
Action exit)
|
||||
{
|
||||
this.owner = owner;
|
||||
trayIcon.Text = "RemSound";
|
||||
this.getSending = getSending;
|
||||
this.toggleSending = toggleSending;
|
||||
this.getReceiving = getReceiving;
|
||||
this.toggleReceiving = toggleReceiving;
|
||||
this.getRecentProfilePaths = getRecentProfilePaths;
|
||||
this.switchToProfile = switchToProfile;
|
||||
this.exit = exit;
|
||||
|
||||
// Initial tooltip text — deliberately NOT just "RemSound" because some screen
|
||||
// readers (NVDA in particular) read tray icons as "<process name>, <tooltip>",
|
||||
// which with a single-word "RemSound" tooltip on a "RemSound" process renders as
|
||||
// "RemSound RemSound" until the first snapshot tick (~1s after launch) overwrites
|
||||
// it. Picking a sensible startup-state string avoids the duplicate read entirely;
|
||||
// the snapshot tick refreshes this with live peer / send / receive info from then
|
||||
// on.
|
||||
trayIcon.Text = "RemSound — starting up";
|
||||
trayIcon.Icon = SystemIcons.Application;
|
||||
trayIcon.Visible = false;
|
||||
trayIcon.DoubleClick += (_, _) => Restore();
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Show", null, (_, _) => Restore());
|
||||
menu.Items.Add("Enable sending", null, (_, _) => enableSending());
|
||||
menu.Items.Add("Enable receiving", null, (_, _) => enableReceiving());
|
||||
menu.Items.Add("Exit", null, (_, _) => exit());
|
||||
|
||||
var showItem = new ToolStripMenuItem("Sho&w RemSound")
|
||||
{
|
||||
AccessibleName = "Show RemSound",
|
||||
};
|
||||
showItem.Click += (_, _) => Restore();
|
||||
|
||||
sendingItem = new ToolStripMenuItem("Enable &sending")
|
||||
{
|
||||
CheckOnClick = false, // we set Checked manually in RefreshMenuState; toggle drives the actual app state via the callback
|
||||
AccessibleName = "Enable sending",
|
||||
};
|
||||
sendingItem.Click += (_, _) => toggleSending();
|
||||
|
||||
receivingItem = new ToolStripMenuItem("Enable &receiving")
|
||||
{
|
||||
CheckOnClick = false,
|
||||
AccessibleName = "Enable receiving",
|
||||
};
|
||||
receivingItem.Click += (_, _) => toggleReceiving();
|
||||
|
||||
profilesItem = new ToolStripMenuItem("&Profiles")
|
||||
{
|
||||
AccessibleName = "Profiles",
|
||||
};
|
||||
// Populate ONCE at construction so WinForms recognises this as a real submenu
|
||||
// (an empty DropDownItems collection means the framework treats the item as a
|
||||
// plain command, never opens the submenu, and DropDownOpening never fires —
|
||||
// which produced "Profiles does nothing" in the first cut of this controller).
|
||||
// After that, every DropDownOpening rebuilds the items so a profile loaded since
|
||||
// the last menu open shows up immediately.
|
||||
RebuildProfilesSubmenu();
|
||||
profilesItem.DropDownOpening += (_, _) => RebuildProfilesSubmenu();
|
||||
|
||||
var exitItem = new ToolStripMenuItem("E&xit")
|
||||
{
|
||||
AccessibleName = "Exit RemSound",
|
||||
};
|
||||
exitItem.Click += (_, _) => exit();
|
||||
|
||||
menu.Items.Add(showItem);
|
||||
menu.Items.Add(sendingItem);
|
||||
menu.Items.Add(receivingItem);
|
||||
menu.Items.Add(profilesItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
// Refresh the checkable items' state every time the menu opens so the visible
|
||||
// ticks match the current main-window state (which can have changed while the
|
||||
// user was clicking around elsewhere).
|
||||
menu.Opening += (_, _) => RefreshMenuState();
|
||||
|
||||
trayIcon.ContextMenuStrip = menu;
|
||||
}
|
||||
|
||||
/// <summary>Replace the tooltip the OS shows over the tray icon. Called from MainForm's
|
||||
/// 1 Hz snapshot tick to keep the text current with peer count + send/receive state.
|
||||
/// Truncated to the Windows 10+ limit (127 chars) — anything longer is silently chopped
|
||||
/// by the shell, so chopping ourselves keeps the truncation point visible.</summary>
|
||||
public void SetTooltip(string text)
|
||||
{
|
||||
// Same anti-duplicate rule as the ctor: avoid a single-word "RemSound" tooltip on
|
||||
// the "RemSound" process, since some screen readers render that as "RemSound RemSound".
|
||||
if (string.IsNullOrEmpty(text)) text = "RemSound — running";
|
||||
if (text.Length > MaxTooltipLength)
|
||||
{
|
||||
text = text[..(MaxTooltipLength - 1)] + "…";
|
||||
}
|
||||
// NotifyIcon.Text throws on the same string-assigning path under some shell
|
||||
// conditions (rare race during a session-end). Best-effort: swallow.
|
||||
try { trayIcon.Text = text; } catch { /* harmless */ }
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (owner.Visible && owner.WindowState != FormWindowState.Minimized) Minimize();
|
||||
@@ -29,11 +157,28 @@ internal sealed class MainFormTrayController : IDisposable
|
||||
public void Restore()
|
||||
{
|
||||
owner.Show();
|
||||
owner.WindowState = FormWindowState.Normal;
|
||||
if (owner.WindowState == FormWindowState.Minimized)
|
||||
{
|
||||
owner.WindowState = FormWindowState.Normal;
|
||||
}
|
||||
owner.BringToFront();
|
||||
owner.Activate();
|
||||
// WinForms' Activate() is best-effort: Windows' foreground-lock feature blocks
|
||||
// arbitrary processes from stealing focus, and Activate() doesn't always win even
|
||||
// for a process that's clearly user-initiated. Calling SetForegroundWindow directly
|
||||
// bypasses the lock because the caller (a tray-menu click handler) is on a UI
|
||||
// thread that received recent user input — which Windows recognises as the
|
||||
// legitimate "user asked for this" case. Without this fix, "Show RemSound" puts
|
||||
// the window on screen but doesn't focus it, leaving NVDA users having to Alt+Tab
|
||||
// to actually hear the new content.
|
||||
try { SetForegroundWindow(owner.Handle); } catch { /* harmless — Restore still mostly worked */ }
|
||||
trayIcon.Visible = false;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
public void Minimize()
|
||||
{
|
||||
owner.Hide();
|
||||
@@ -41,4 +186,57 @@ internal sealed class MainFormTrayController : IDisposable
|
||||
}
|
||||
|
||||
public void Dispose() => trayIcon.Dispose();
|
||||
|
||||
private void RefreshMenuState()
|
||||
{
|
||||
// Live state of the two togglable items — read on demand from MainForm so a checkbox
|
||||
// change made via the main window or a global hotkey is reflected in the tray menu
|
||||
// the next time the user opens it.
|
||||
try { sendingItem.Checked = getSending(); } catch { sendingItem.Checked = false; }
|
||||
try { receivingItem.Checked = getReceiving(); } catch { receivingItem.Checked = false; }
|
||||
}
|
||||
|
||||
private void RebuildProfilesSubmenu()
|
||||
{
|
||||
profilesItem.DropDownItems.Clear();
|
||||
IReadOnlyList<string> paths;
|
||||
try { paths = getRecentProfilePaths(); }
|
||||
catch { paths = Array.Empty<string>(); }
|
||||
|
||||
var slot = 1;
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) continue;
|
||||
if (!File.Exists(path)) continue; // skip missing files; the AppConfig list keeps the entry in case it reappears
|
||||
var title = Path.GetFileNameWithoutExtension(path);
|
||||
// Mnemonic prefix matches the File menu's Recent profiles submenu (&1..&5) so
|
||||
// muscle-memory between the in-app menu and the tray menu carries over. The
|
||||
// visible Text carries the number; AccessibleName is just the profile name so
|
||||
// NVDA reads "MyProfile, menu item, one of five" rather than the noisier
|
||||
// "Recent profile 1: MyProfile" that the original code was reading out.
|
||||
var item = new ToolStripMenuItem($"&{slot} {title}")
|
||||
{
|
||||
AccessibleName = title,
|
||||
Tag = path,
|
||||
};
|
||||
item.Click += (s, _) =>
|
||||
{
|
||||
var sender = (ToolStripMenuItem)s!;
|
||||
var profilePath = (string)sender.Tag!;
|
||||
try { switchToProfile(profilePath); }
|
||||
catch { /* the switch path surfaces its own errors via MainForm */ }
|
||||
};
|
||||
profilesItem.DropDownItems.Add(item);
|
||||
slot++;
|
||||
}
|
||||
|
||||
if (profilesItem.DropDownItems.Count == 0)
|
||||
{
|
||||
profilesItem.DropDownItems.Add(new ToolStripMenuItem("(No recent profiles)")
|
||||
{
|
||||
Enabled = false,
|
||||
AccessibleName = "No recent profiles",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// Per-cue enable list (2026-05-15). Replaces the single "mute connect/disconnect"
|
||||
// checkbox with one item per cue sound, ticked = play, unticked = silent. Same
|
||||
// CheckOnClick / mnemonic-via-label pattern as the audio device lists on the main
|
||||
// form — visually familiar and NVDA-friendly. The Items collection order MUST match
|
||||
// the CueIndex enum below so the ItemCheck handler can dispatch by index.
|
||||
// Audio cue UI (2026-05-28 revised after Ed's feedback that one-control-per-cue blew
|
||||
// out the tab order). Back to a single CheckedListBox — up/down arrows move between
|
||||
// cues, Space toggles enable, exactly as it always was. Two buttons sit BELOW the list:
|
||||
// a Play button to preview, and a Browse button to pick a custom WAV. Both act on
|
||||
// whichever cue is currently selected in the list. Their labels update live as the
|
||||
// selection changes ("Play disconnect sound", "Browse for disconnect sound...") so
|
||||
// sighted and NVDA users alike know which cue they're about to act on. Tab order in
|
||||
// the cue section is just: list → Play → Browse (three tab stops, not eighteen).
|
||||
private readonly Label cueListLabel = new()
|
||||
{
|
||||
Text = "Audio cue sou&nds (Alt+N):",
|
||||
@@ -57,18 +60,49 @@ internal sealed class PreferencesDialog : Form
|
||||
{
|
||||
CheckOnClick = true,
|
||||
IntegralHeight = false,
|
||||
Height = 100,
|
||||
Height = 130,
|
||||
Width = 360,
|
||||
AccessibleName = "Audio cue sounds",
|
||||
};
|
||||
|
||||
private enum CueIndex
|
||||
private readonly Button playSelectedCueButton = new()
|
||||
{
|
||||
Connect = 0,
|
||||
Disconnect = 1,
|
||||
RecordStart = 2,
|
||||
RecordStop = 3,
|
||||
}
|
||||
AutoSize = true,
|
||||
Padding = new Padding(6, 2, 6, 2),
|
||||
};
|
||||
|
||||
private readonly Button browseSelectedCueButton = new()
|
||||
{
|
||||
AutoSize = true,
|
||||
Padding = new Padding(6, 2, 6, 2),
|
||||
};
|
||||
|
||||
/// <summary>Describes one cue. <see cref="DisplayName"/> ends up in the listbox row;
|
||||
/// <see cref="CueId"/> is the well-known key from <see cref="MainForm.CueId"/>; the
|
||||
/// LoadEnabled / SaveEnabled pair routes the checkbox state to the right
|
||||
/// <see cref="RemSoundSettingsStore"/> getter/setter so we don't need a hard-coded
|
||||
/// switch on index.</summary>
|
||||
private sealed record CueRowDescriptor(
|
||||
string DisplayName,
|
||||
string CueId,
|
||||
Func<RemSoundSettingsStore, bool> LoadEnabled,
|
||||
Action<RemSoundSettingsStore, bool> SaveEnabled);
|
||||
|
||||
private static readonly CueRowDescriptor[] CueRows =
|
||||
[
|
||||
new("Connect sound", MainForm.CueId.Connect,
|
||||
s => s.LoadEnableConnectCue(), (s, v) => s.SaveEnableConnectCue(v)),
|
||||
new("Disconnect sound", MainForm.CueId.Disconnect,
|
||||
s => s.LoadEnableDisconnectCue(), (s, v) => s.SaveEnableDisconnectCue(v)),
|
||||
new("Recording start sound", MainForm.CueId.RecordStart,
|
||||
s => s.LoadEnableRecordStartCue(), (s, v) => s.SaveEnableRecordStartCue(v)),
|
||||
new("Recording stop sound", MainForm.CueId.RecordStop,
|
||||
s => s.LoadEnableRecordStopCue(), (s, v) => s.SaveEnableRecordStopCue(v)),
|
||||
new("Profile saved sound", MainForm.CueId.Save,
|
||||
s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)),
|
||||
new("Profile switched sound", MainForm.CueId.ProfileSwitch,
|
||||
s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)),
|
||||
];
|
||||
|
||||
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
|
||||
{
|
||||
@@ -223,30 +257,80 @@ internal sealed class PreferencesDialog : Form
|
||||
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
};
|
||||
|
||||
// Populate the cue list — order must match CueIndex enum. Each item is ticked from
|
||||
// its corresponding settings flag; the toggle handler dispatches by index so adding
|
||||
// a future cue is just two lines (enum value + Items.Add + Save case).
|
||||
// Populate the cue listbox — order matches the CueRows array, and the index of a
|
||||
// selected row maps 1:1 to a CueRowDescriptor. Each row's ticked state is loaded
|
||||
// from the active profile's per-cue enable flag via the descriptor.
|
||||
cueList.Items.Clear();
|
||||
cueList.Items.Add("Connect sound", settings.LoadEnableConnectCue());
|
||||
cueList.Items.Add("Disconnect sound", settings.LoadEnableDisconnectCue());
|
||||
cueList.Items.Add("Recording start sound", settings.LoadEnableRecordStartCue());
|
||||
cueList.Items.Add("Recording stop sound", settings.LoadEnableRecordStopCue());
|
||||
foreach (var c in CueRows)
|
||||
{
|
||||
cueList.Items.Add(c.DisplayName, c.LoadEnabled(settings));
|
||||
}
|
||||
if (cueList.Items.Count > 0) cueList.SelectedIndex = 0;
|
||||
cueList.ItemCheck += (_, e) =>
|
||||
{
|
||||
// ItemCheck fires BEFORE the visual state actually flips; e.NewValue is what
|
||||
// it's about to become. Use that for the persist call so the saved value
|
||||
// matches what the user just clicked.
|
||||
// it's about to become, so the persisted value matches what the user just
|
||||
// clicked.
|
||||
if (e.Index < 0 || e.Index >= CueRows.Length) return;
|
||||
var nowEnabled = e.NewValue == CheckState.Checked;
|
||||
switch ((CueIndex)e.Index)
|
||||
{
|
||||
case CueIndex.Connect: settings.SaveEnableConnectCue(nowEnabled); break;
|
||||
case CueIndex.Disconnect: settings.SaveEnableDisconnectCue(nowEnabled); break;
|
||||
case CueIndex.RecordStart: settings.SaveEnableRecordStartCue(nowEnabled); break;
|
||||
case CueIndex.RecordStop: settings.SaveEnableRecordStopCue(nowEnabled); break;
|
||||
}
|
||||
CueRows[e.Index].SaveEnabled(settings, nowEnabled);
|
||||
ChangedAnyProfileSetting = true;
|
||||
};
|
||||
|
||||
// Selection changes update the two action buttons' labels so they always tell the
|
||||
// user which cue they're about to act on. Refreshed eagerly at construction time
|
||||
// for the initial selection too.
|
||||
cueList.SelectedIndexChanged += (_, _) => RefreshCueActionButtons(settings);
|
||||
RefreshCueActionButtons(settings);
|
||||
|
||||
playSelectedCueButton.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
OnPlayClicked(CueRows[cueList.SelectedIndex], settings);
|
||||
};
|
||||
browseSelectedCueButton.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
OnBrowseClicked(browseSelectedCueButton, CueRows[cueList.SelectedIndex], settings);
|
||||
RefreshCueActionButtons(settings);
|
||||
};
|
||||
|
||||
// Right-click "Use default sound" context menu lives on the Browse button. It acts
|
||||
// on whichever cue is currently selected — same as a left click. Disabled when no
|
||||
// override is set so it can't accidentally do nothing.
|
||||
var browseCtx = new ContextMenuStrip();
|
||||
var useDefaultItem = new ToolStripMenuItem("Use default sound");
|
||||
useDefaultItem.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
var cue = CueRows[cueList.SelectedIndex];
|
||||
if (settings.LoadCustomCuePath(cue.CueId) is not null)
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, null);
|
||||
ChangedAnyProfileSetting = true;
|
||||
RefreshCueActionButtons(settings);
|
||||
}
|
||||
};
|
||||
browseCtx.Opening += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length)
|
||||
{
|
||||
useDefaultItem.Enabled = false;
|
||||
useDefaultItem.Text = "Use default sound";
|
||||
}
|
||||
else
|
||||
{
|
||||
var cue = CueRows[cueList.SelectedIndex];
|
||||
useDefaultItem.Enabled = settings.LoadCustomCuePath(cue.CueId) is not null;
|
||||
useDefaultItem.Text = $"Use default {cue.DisplayName.ToLowerInvariant()}";
|
||||
useDefaultItem.AccessibleName = useDefaultItem.Text;
|
||||
}
|
||||
};
|
||||
browseCtx.Items.Add(useDefaultItem);
|
||||
browseSelectedCueButton.ContextMenuStrip = browseCtx;
|
||||
|
||||
cueListLabel.Click += (_, _) => cueList.Focus();
|
||||
|
||||
acceptRemoteVolumeBox.Checked = settings.LoadAcceptRemoteVolumeCommands();
|
||||
acceptRemoteVolumeBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
@@ -344,22 +428,25 @@ internal sealed class PreferencesDialog : Form
|
||||
for (var i = 0; i < 12; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
|
||||
// Tab order top-to-bottom: browse, cue-sound list, accept remote, check-on-startup,
|
||||
// frequency, check-now, silent install, UPnP, enable logs, write logs now, close.
|
||||
// Updates sit above the log row so a user setting up the app meets them first. The
|
||||
// Startup behaviour button used to live here at tab index 3; it moved to the Options
|
||||
// menu in the 2026-05-15 reorg.
|
||||
// Tab order top-to-bottom: browse-profiles-folder → cue list → Play selected →
|
||||
// Browse for selected → accept remote → check-on-startup → frequency → check-now →
|
||||
// silent install → UPnP → enable logs → write logs now → close. The cue section is
|
||||
// three tab stops total: the list itself (where up/down navigates between cues and
|
||||
// Space toggles enable), then the two action buttons that operate on whichever cue
|
||||
// is currently selected in the list.
|
||||
browseProfilesFolderButton.TabIndex = 0;
|
||||
cueList.TabIndex = 1;
|
||||
acceptRemoteVolumeBox.TabIndex = 2;
|
||||
checkForUpdatesOnStartupBox.TabIndex = 3;
|
||||
updateFrequencyBox.TabIndex = 4;
|
||||
checkForUpdatesNowButton.TabIndex = 5;
|
||||
silentlyInstallUpdatesBox.TabIndex = 6;
|
||||
upnpEnabledBox.TabIndex = 7;
|
||||
loggingBox.TabIndex = 8;
|
||||
writeLogsNowButton.TabIndex = 9;
|
||||
closeButton.TabIndex = 10;
|
||||
playSelectedCueButton.TabIndex = 2;
|
||||
browseSelectedCueButton.TabIndex = 3;
|
||||
acceptRemoteVolumeBox.TabIndex = 4;
|
||||
checkForUpdatesOnStartupBox.TabIndex = 5;
|
||||
updateFrequencyBox.TabIndex = 6;
|
||||
checkForUpdatesNowButton.TabIndex = 7;
|
||||
silentlyInstallUpdatesBox.TabIndex = 8;
|
||||
upnpEnabledBox.TabIndex = 9;
|
||||
loggingBox.TabIndex = 10;
|
||||
writeLogsNowButton.TabIndex = 11;
|
||||
closeButton.TabIndex = 12;
|
||||
|
||||
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
|
||||
// sits inline next to the combo while keeping the combo as the focusable target.
|
||||
@@ -375,21 +462,34 @@ internal sealed class PreferencesDialog : Form
|
||||
freqRow.Controls.Add(updateFrequencyLabel);
|
||||
freqRow.Controls.Add(updateFrequencyBox);
|
||||
|
||||
// Wrap label + list as one logical group so they share the same row in the
|
||||
// top-level layout. The label's Alt+N mnemonic focuses the list when activated.
|
||||
// Group the cue label + list + the two action buttons into a single panel that
|
||||
// occupies one row in the outer layout. The action buttons sit side-by-side under
|
||||
// the list so they read as "buttons that act on the list above" without taking up
|
||||
// a second row of vertical space.
|
||||
var cueGroup = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
RowCount = 3,
|
||||
};
|
||||
cueGroup.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
var cueActions = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(0, 4, 0, 0),
|
||||
};
|
||||
cueActions.Controls.Add(playSelectedCueButton);
|
||||
cueActions.Controls.Add(browseSelectedCueButton);
|
||||
cueGroup.Controls.Add(cueListLabel, 0, 0);
|
||||
cueGroup.Controls.Add(cueList, 0, 1);
|
||||
cueListLabel.Click += (_, _) => cueList.Focus();
|
||||
cueGroup.Controls.Add(cueActions, 0, 2);
|
||||
|
||||
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
|
||||
panel.Controls.Add(cueGroup, 0, 1);
|
||||
@@ -429,6 +529,156 @@ internal sealed class PreferencesDialog : Form
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Refresh the Play and Browse action buttons so their visible text and
|
||||
/// AccessibleName reflect the currently-selected cue. Called on every selection change
|
||||
/// in the cue listbox AND immediately after a Browse pick (the "(custom)" tag flips
|
||||
/// based on whether a custom path is set). When the selection is empty — e.g. the
|
||||
/// listbox briefly clears during a profile reload — both buttons get a generic label
|
||||
/// and are disabled so a stray click can't act on a stale index.</summary>
|
||||
private void RefreshCueActionButtons(RemSoundSettingsStore settings)
|
||||
{
|
||||
var idx = cueList.SelectedIndex;
|
||||
if (idx < 0 || idx >= CueRows.Length)
|
||||
{
|
||||
playSelectedCueButton.Text = "&Play selected sound";
|
||||
playSelectedCueButton.AccessibleName = "Play selected sound";
|
||||
playSelectedCueButton.Enabled = false;
|
||||
browseSelectedCueButton.Text = "&Browse for selected sound...";
|
||||
browseSelectedCueButton.AccessibleName = "Browse for selected sound";
|
||||
browseSelectedCueButton.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var cue = CueRows[idx];
|
||||
playSelectedCueButton.Enabled = true;
|
||||
playSelectedCueButton.Text = $"&Play {cue.DisplayName.ToLowerInvariant()}";
|
||||
playSelectedCueButton.AccessibleName = $"Play {cue.DisplayName.ToLowerInvariant()}";
|
||||
|
||||
var customPath = settings.LoadCustomCuePath(cue.CueId);
|
||||
browseSelectedCueButton.Enabled = true;
|
||||
if (string.IsNullOrWhiteSpace(customPath))
|
||||
{
|
||||
browseSelectedCueButton.Text = $"&Browse for {cue.DisplayName.ToLowerInvariant()}...";
|
||||
browseSelectedCueButton.AccessibleName = $"Browse for {cue.DisplayName.ToLowerInvariant()}, currently using the default sound";
|
||||
}
|
||||
else
|
||||
{
|
||||
var filename = Path.GetFileName(customPath);
|
||||
browseSelectedCueButton.Text = $"&Browse for {cue.DisplayName.ToLowerInvariant()}... (custom)";
|
||||
browseSelectedCueButton.AccessibleName = $"Browse for {cue.DisplayName.ToLowerInvariant()}, currently using your file {filename}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the WAV file currently configured for a cue: the user's custom
|
||||
/// override if set and on disk, otherwise the bundled default in <c>sounds\</c>.
|
||||
/// Returns null when neither resolves to an existing file (typical for save.wav /
|
||||
/// profile.wav before the project owner supplies them) so the caller can stay silent.
|
||||
/// Mirrors the resolution order in MainForm.TryLoadCueSound — the Play button must
|
||||
/// preview exactly what the cue would play if it fired now. Reads through the settings
|
||||
/// cache so we see whatever the user has changed in this dialog session, including
|
||||
/// custom paths not yet persisted to the profile JSON.</summary>
|
||||
private static string? ResolveCueFilePath(CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
{
|
||||
var customPath = settings.LoadCustomCuePath(cue.CueId);
|
||||
if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath))
|
||||
{
|
||||
return customPath;
|
||||
}
|
||||
// Default WAV filename is built from the cue ID — same convention as MainForm.
|
||||
// The dictionary kept here makes the mapping explicit and lets us pretty-print
|
||||
// "record start" / "record stop" with the space rather than the cue ID's hyphen.
|
||||
var defaultFileName = cue.CueId switch
|
||||
{
|
||||
MainForm.CueId.Connect => "connect.wav",
|
||||
MainForm.CueId.Disconnect => "disconnect.wav",
|
||||
MainForm.CueId.RecordStart => "record start.wav",
|
||||
MainForm.CueId.RecordStop => "record stop.wav",
|
||||
MainForm.CueId.Save => "save.wav",
|
||||
MainForm.CueId.ProfileSwitch => "profile.wav",
|
||||
_ => null,
|
||||
};
|
||||
if (defaultFileName is null) return null;
|
||||
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
|
||||
return File.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
/// <summary>Preview a cue's currently-configured WAV through the system default audio
|
||||
/// output. Plays asynchronously (SoundPlayer.Play loads + plays on a thread-pool thread),
|
||||
/// so the dialog stays responsive even if the file is briefly slow to load. When no file
|
||||
/// resolves — e.g. a cue without a default WAV and no custom path — show a small popup
|
||||
/// so the user knows why nothing happened, rather than silently doing nothing and
|
||||
/// leaving them wondering whether the Play button worked.</summary>
|
||||
private void OnPlayClicked(CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
{
|
||||
var path = ResolveCueFilePath(cue, settings);
|
||||
if (path is null)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"No sound is currently configured for the {cue.DisplayName.ToLowerInvariant()}. " +
|
||||
$"Use the Browse button on this row to pick a WAV file.",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var sp = new System.Media.SoundPlayer(path);
|
||||
sp.Play();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not play {Path.GetFileName(path)}: {ex.Message}",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Open a WAV file picker for the given cue. The picker defaults to the user's
|
||||
/// previously-set custom path if one exists, falling back to the bundled sounds\ folder
|
||||
/// next to RemSound.exe — so picking a file from inside that folder is treated as
|
||||
/// "use the default" and the override is cleared rather than re-pointed at the same
|
||||
/// file (which would leave the user stuck with a stale copy if a future RemSound update
|
||||
/// replaces the default WAV). Writes through the settings cache, since custom cue paths
|
||||
/// are per-profile — clearing here also flips ChangedAnyProfileSetting so the save-prompt
|
||||
/// fires on the way out.</summary>
|
||||
private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
{
|
||||
var soundsFolder = Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||
var existing = settings.LoadCustomCuePath(cue.CueId);
|
||||
var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing)
|
||||
? Path.GetDirectoryName(existing) ?? soundsFolder
|
||||
: soundsFolder;
|
||||
using var picker = new OpenFileDialog
|
||||
{
|
||||
Title = $"Choose a WAV file for {cue.DisplayName}",
|
||||
Filter = "WAV files (*.wav)|*.wav",
|
||||
CheckFileExists = true,
|
||||
InitialDirectory = initialDir,
|
||||
DereferenceLinks = true,
|
||||
};
|
||||
if (picker.ShowDialog(this) != DialogResult.OK) return;
|
||||
if (string.IsNullOrWhiteSpace(picker.FileName)) return;
|
||||
|
||||
var pickedFullPath = Path.GetFullPath(picker.FileName);
|
||||
var soundsFolderFullPath = Path.GetFullPath(soundsFolder);
|
||||
|
||||
// If the user picked a file inside the bundled sounds\ folder, treat it as a "use
|
||||
// default" — clear the override rather than store the path. Avoids freezing the
|
||||
// user on a specific shipped-default file across updates.
|
||||
if (pickedFullPath.StartsWith(soundsFolderFullPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, pickedFullPath);
|
||||
}
|
||||
ChangedAnyProfileSetting = true;
|
||||
// Refresh the visible action-button labels so the "(custom)" tag appears or
|
||||
// disappears right away. Belt-and-braces: the caller also refreshes, but doing it
|
||||
// here makes the function self-consistent.
|
||||
RefreshCueActionButtons(settings);
|
||||
}
|
||||
|
||||
/// <summary>Pull the latest UPnP snapshot and update the inline status label. Always
|
||||
/// called on the UI thread (either inline from a change handler or marshaled in from
|
||||
/// the StatusChanged subscription).</summary>
|
||||
|
||||
@@ -33,6 +33,12 @@ internal sealed class RecordingController
|
||||
|
||||
public bool IsRecording => active is not null;
|
||||
|
||||
/// <summary>UTC clock at which the current recording started, or null when nothing is
|
||||
/// recording. Captured by <see cref="Start"/> and cleared by <see cref="Stop"/>. Used
|
||||
/// by the system-tray tooltip builder in MainForm to surface "recording for MM:SS"
|
||||
/// alongside the peer count. 2026-05-28.</summary>
|
||||
public DateTime? RecordingStartedUtc { get; private set; }
|
||||
|
||||
/// <summary>Optional callback fired when the user starts or stops a recording. The
|
||||
/// MainForm hooks this to flip the menu item text "Start recording" ↔ "Stop recording"
|
||||
/// and announce the change to NVDA.</summary>
|
||||
@@ -64,6 +70,7 @@ internal sealed class RecordingController
|
||||
// whether to actually write the samples.
|
||||
sender.OnSentSamples = active.WriteSent;
|
||||
receiver.OnReceivedSamples = active.WriteReceived;
|
||||
RecordingStartedUtc = DateTime.UtcNow;
|
||||
diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})");
|
||||
RecordingStateChanged?.Invoke(true);
|
||||
}
|
||||
@@ -81,6 +88,7 @@ internal sealed class RecordingController
|
||||
receiver.OnReceivedSamples = null;
|
||||
|
||||
active = null;
|
||||
RecordingStartedUtc = null;
|
||||
try
|
||||
{
|
||||
recorder.Stop();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>3.0.2</Version>
|
||||
<Version>3.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -57,24 +57,37 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Peer-state notification sounds. Copied next to RemSound.exe so SoundPlayer can find
|
||||
them via AppContext.BaseDirectory. -->
|
||||
<Content Include="..\..\connect.wav">
|
||||
<Link>connect.wav</Link>
|
||||
<!-- All cue sounds live in a sounds\ subfolder under the publish output (moved out of
|
||||
the install-root flat layout 2026-05-28). Default cue WAVs ship inside that folder;
|
||||
user-supplied custom cue paths set in Preferences override the defaults at runtime.
|
||||
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
|
||||
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
|
||||
<Content Include="..\..\sounds\connect.wav">
|
||||
<Link>sounds\connect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\disconnect.wav">
|
||||
<Link>disconnect.wav</Link>
|
||||
<Content Include="..\..\sounds\disconnect.wav">
|
||||
<Link>sounds\disconnect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- Recording start/stop cues. Filenames contain a space — preserve on copy so the
|
||||
load-by-filename path in TryLoadCueSound finds them exactly as written. -->
|
||||
<Content Include="..\..\record start.wav">
|
||||
<Link>record start.wav</Link>
|
||||
<Content Include="..\..\sounds\record start.wav">
|
||||
<Link>sounds\record start.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\record stop.wav">
|
||||
<Link>record stop.wav</Link>
|
||||
<Content Include="..\..\sounds\record stop.wav">
|
||||
<Link>sounds\record stop.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- New cues introduced 2026-05-28: save.wav fires after a successful Save / Save As;
|
||||
profile.wav fires immediately after a profile finishes loading. Both default WAVs
|
||||
to be supplied by the project owner; absent files are gracefully ignored at load
|
||||
time (the cue just doesn't play) so the build is fine without them. -->
|
||||
<Content Include="..\..\sounds\save.wav" Condition="Exists('..\..\sounds\save.wav')">
|
||||
<Link>sounds\save.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\sounds\profile.wav" Condition="Exists('..\..\sounds\profile.wav')">
|
||||
<Link>sounds\profile.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
|
||||
@@ -97,11 +97,25 @@ public sealed class Profile
|
||||
/// falls back to the legacy <see cref="MuteConnectionCues"/> migration path; once the
|
||||
/// user touches the new UI we write a concrete <c>true</c>/<c>false</c> and the legacy
|
||||
/// field stops mattering. Defaults to "play the sound" (true) for both cases — the
|
||||
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.</summary>
|
||||
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.
|
||||
///
|
||||
/// EnableSaveCue / EnableProfileSwitchCue added 2026-05-28 alongside the new
|
||||
/// save.wav / profile.wav defaults. Same nullable + default-true semantics as the rest.</summary>
|
||||
public bool? EnableConnectCue { get; set; }
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public bool? EnableSaveCue { get; set; }
|
||||
public bool? EnableProfileSwitchCue { get; set; }
|
||||
|
||||
/// <summary>Per-cue custom WAV file overrides, keyed by the well-known cue id (<c>connect</c>,
|
||||
/// <c>disconnect</c>, <c>record-start</c>, <c>record-stop</c>, <c>save</c>,
|
||||
/// <c>profile-switch</c>) and valued with the absolute filesystem path to the user's chosen
|
||||
/// WAV. Per-profile (moved here from AppConfig 2026-05-28) so a "live monitoring" profile
|
||||
/// can have one set of custom sounds and a "recording" profile a different set. Missing
|
||||
/// keys mean "use the default sound shipped in the sounds\ folder next to RemSound.exe".
|
||||
/// Empty dictionary on a fresh profile.</summary>
|
||||
public Dictionary<string, string> CustomCuePaths { get; set; } = new();
|
||||
public int MaxLatencyMs { get; set; } = 80;
|
||||
public int Smoothness { get; set; } = 3;
|
||||
public bool ContinuousAutoTuneEnabled { get; set; }
|
||||
|
||||
@@ -408,6 +408,60 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableSaveCue() =>
|
||||
Try(() => Load()?.EnableSaveCue) ?? true;
|
||||
|
||||
public void SaveEnableSaveCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableSaveCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableProfileSwitchCue() =>
|
||||
Try(() => Load()?.EnableProfileSwitchCue) ?? true;
|
||||
|
||||
public void SaveEnableProfileSwitchCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableProfileSwitchCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>The user's custom WAV path for a given cue, or null when they're using the
|
||||
/// default. Per-profile (lives on <see cref="Profile.CustomCuePaths"/>) so different
|
||||
/// profiles can carry different cue palettes.</summary>
|
||||
public string? LoadCustomCuePath(string cueId)
|
||||
{
|
||||
return Try(() =>
|
||||
{
|
||||
var s = Load();
|
||||
if (s?.CustomCuePaths is { } dict && dict.TryGetValue(cueId, out var path)
|
||||
&& !string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
return (string?)null;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Set a custom WAV path for a given cue. Pass null or empty to clear the
|
||||
/// override (the cue reverts to the bundled default in <c>sounds\</c>).</summary>
|
||||
public void SaveCustomCuePath(string cueId, string? path)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.CustomCuePaths ??= new Dictionary<string, string>();
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
s.CustomCuePaths.Remove(cueId);
|
||||
}
|
||||
else
|
||||
{
|
||||
s.CustomCuePaths[cueId] = path;
|
||||
}
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
|
||||
/// CLONE so callers can mutate the returned object without inadvertently writing
|
||||
/// back to the cache. Save flushes the object atomically.</summary>
|
||||
@@ -531,6 +585,12 @@ public sealed class RemSoundSettingsStore
|
||||
EnableDisconnectCue = profile.EnableDisconnectCue,
|
||||
EnableRecordStartCue = profile.EnableRecordStartCue,
|
||||
EnableRecordStopCue = profile.EnableRecordStopCue,
|
||||
EnableSaveCue = profile.EnableSaveCue,
|
||||
EnableProfileSwitchCue = profile.EnableProfileSwitchCue,
|
||||
// Defensive copy so cache mutations don't leak into the in-memory Profile graph
|
||||
// (and vice-versa). Profile is loaded once at startup; the cache evolves through
|
||||
// the session and is written back via CopyTo on save.
|
||||
CustomCuePaths = profile.CustomCuePaths is null ? new() : new Dictionary<string, string>(profile.CustomCuePaths),
|
||||
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
|
||||
};
|
||||
}
|
||||
@@ -581,6 +641,11 @@ public sealed class RemSoundSettingsStore
|
||||
profile.EnableDisconnectCue = s.EnableDisconnectCue;
|
||||
profile.EnableRecordStartCue = s.EnableRecordStartCue;
|
||||
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
||||
profile.EnableSaveCue = s.EnableSaveCue;
|
||||
profile.EnableProfileSwitchCue = s.EnableProfileSwitchCue;
|
||||
profile.CustomCuePaths = s.CustomCuePaths is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(s.CustomCuePaths);
|
||||
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
|
||||
}
|
||||
|
||||
@@ -646,6 +711,9 @@ public sealed class RemSoundSettingsStore
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public bool? EnableSaveCue { get; set; }
|
||||
public bool? EnableProfileSwitchCue { get; set; }
|
||||
public Dictionary<string, string>? CustomCuePaths { get; set; }
|
||||
public RecordingSettings? RecordingSettings { get; set; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user