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
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user