Release v4.3: speak status line (Tolk) + Logging tab with housekeeping
- New screen-reader hotkey "Speak the RemSound status information" (issue #13): reads the status line aloud through the active screen reader via Tolk, fires from anywhere (system-wide), unset by default. Built behind an IScreenReaderOutput seam so a future build can swap Tolk for Prism on Windows 10+ without touching callers. Tolk DLLs vendored under tolk/ and shipped next to the exe. - New Logging tab in Preferences: Enable logs + Write logs now moved there, plus opt-in startup "warn if logs folder exceeds N MB" and "delete logs older than N days", and a "Delete all logs" button (Yes/No confirm). New LogMaintenance helper + AppConfig settings drive it. - Manual (readme.html + regenerated MANUAL.md), About changelog and RELEASE_NOTES updated in plain English; csproj <Version> bumped to 4.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
034a7de632
commit
fd5c31740a
@@ -20,6 +20,31 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v4.3
|
||||
|
||||
Two additions for screen-reader users, and a
|
||||
tidier home for logging.
|
||||
|
||||
You can now hear the RemSound status line on
|
||||
demand. In the Keyboard shortcuts dialog set a
|
||||
key for "Speak the RemSound status information",
|
||||
and pressing it reads the whole status line aloud
|
||||
through your screen reader — how long you've been
|
||||
connected, your peers, whether sound is flowing,
|
||||
and how healthy the link is — from anywhere, even
|
||||
with RemSound in the tray. It's there for the
|
||||
times your screen reader can't read the status
|
||||
line itself. Unset to begin with, so you pick the
|
||||
key.
|
||||
|
||||
Logging now has its own tab in Preferences, and it
|
||||
can keep its own folder tidy: warn you at startup
|
||||
if the logs folder grows past a size you choose,
|
||||
automatically delete logs older than a number of
|
||||
days you set, and a "Delete all logs" button to
|
||||
clear them out at once. All three are off unless
|
||||
you turn them on.
|
||||
|
||||
RemSound v4.2
|
||||
|
||||
Three fixes, one of them a real annoyance gone.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over a screen-reader speech backend, so RemSound can speak text straight to whatever
|
||||
/// screen reader is running (NVDA, JAWS, SAPI, ...). Needed for feedback the screen reader can't
|
||||
/// otherwise observe — most importantly a GLOBAL hotkey firing while RemSound isn't focused, where
|
||||
/// NVDA reads nothing on its own.
|
||||
///
|
||||
/// Kept as an interface purely so the concrete backend can be swapped later without touching callers.
|
||||
/// Today the only implementation is <see cref="TolkScreenReaderOutput"/>, which works on every
|
||||
/// Windows version RemSound supports (including Windows 7). Prism (evaluated 2026-06-19) is the more
|
||||
/// modern option but hard-requires Windows 10+, so it can't replace Tolk while Win7 is supported — if
|
||||
/// that changes, add a PrismScreenReaderOutput and pick it per-OS in <see cref="ScreenReader"/>.
|
||||
/// </summary>
|
||||
internal interface IScreenReaderOutput
|
||||
{
|
||||
/// <summary>Speak <paramref name="text"/> through the active screen reader. <paramref name="interrupt"/>
|
||||
/// true cuts off whatever it's currently saying. Returns true if the text was handed to a screen
|
||||
/// reader, false if none is available. Best-effort — never throws.</summary>
|
||||
bool Speak(string text, bool interrupt = true);
|
||||
|
||||
/// <summary>Release the backend. Safe to call more than once.</summary>
|
||||
void Shutdown();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Housekeeping for the <c>logs\</c> folder, driven by the Logging-tab preferences: report the
|
||||
/// folder's total size (for the startup over-size warning), prune log files older than N days, and
|
||||
/// delete every log on demand. All operations are best-effort — a locked or vanished file is
|
||||
/// skipped, never thrown, because log housekeeping must never stop the app from running.
|
||||
/// </summary>
|
||||
internal static class LogMaintenance
|
||||
{
|
||||
/// <summary>Total size in bytes of every <c>*.log</c> file in the logs folder. 0 if the folder
|
||||
/// doesn't exist yet or can't be read.</summary>
|
||||
public static long LogsFolderSizeBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = AppConfig.LogsDirectory;
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
long total = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
|
||||
{
|
||||
try { total += new FileInfo(file).Length; } catch { /* vanished mid-scan — skip */ }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>Delete <c>*.log</c> files last written more than <paramref name="days"/> days ago.
|
||||
/// The currently-open log (<paramref name="activeLogPath"/>) is always spared even if the clock
|
||||
/// makes it look old. Returns how many files were deleted.</summary>
|
||||
public static int PruneLogsOlderThan(int days, string? activeLogPath)
|
||||
{
|
||||
if (days < 1) return 0;
|
||||
var deleted = 0;
|
||||
try
|
||||
{
|
||||
var dir = AppConfig.LogsDirectory;
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
var cutoff = DateTime.Now.AddDays(-days);
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
|
||||
{
|
||||
if (IsSamePath(file, activeLogPath)) continue;
|
||||
try
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < cutoff)
|
||||
{
|
||||
File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
catch { /* locked / in use — leave it */ }
|
||||
}
|
||||
}
|
||||
catch { /* folder enumeration failed — give up quietly */ }
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>Delete every <c>*.log</c> file except the one currently being written
|
||||
/// (<paramref name="activeLogPath"/>, which is held open and can't be removed anyway). Returns
|
||||
/// how many files were deleted.</summary>
|
||||
public static int DeleteAllLogs(string? activeLogPath)
|
||||
{
|
||||
var deleted = 0;
|
||||
try
|
||||
{
|
||||
var dir = AppConfig.LogsDirectory;
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
|
||||
{
|
||||
if (IsSamePath(file, activeLogPath)) continue;
|
||||
try { File.Delete(file); deleted++; }
|
||||
catch { /* locked / in use — leave it */ }
|
||||
}
|
||||
}
|
||||
catch { /* give up quietly */ }
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static bool IsSamePath(string a, string? b) =>
|
||||
!string.IsNullOrEmpty(b)
|
||||
&& string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -716,7 +716,10 @@ public sealed class MainForm : Form
|
||||
() => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0),
|
||||
() => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0),
|
||||
() => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0),
|
||||
ShowQuickProfileSwitch);
|
||||
ShowQuickProfileSwitch,
|
||||
// Speak the status line aloud through the active screen reader (issue #13). Screen-reader
|
||||
// specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts).
|
||||
SpeakStatusLine);
|
||||
// Pipe hotkey controller diagnostics into the main log so we can see, e.g.,
|
||||
// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and
|
||||
// "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409:
|
||||
@@ -1235,6 +1238,7 @@ public sealed class MainForm : Form
|
||||
}
|
||||
|
||||
hotkeyController.Dispose();
|
||||
ScreenReader.Shutdown();
|
||||
trayController.Dispose();
|
||||
logFile.Dispose();
|
||||
};
|
||||
@@ -1379,11 +1383,54 @@ public sealed class MainForm : Form
|
||||
if (IsDisposed) return;
|
||||
MaybeShowWhatsNewAfterUpdate();
|
||||
if (IsDisposed) return;
|
||||
MaybeRunLogHousekeeping();
|
||||
if (IsDisposed) return;
|
||||
MaybeWarnAboutRealtekAsio();
|
||||
if (IsDisposed) return;
|
||||
MaybeWarnMicBlockedOnStartup();
|
||||
}
|
||||
|
||||
/// <summary>Startup log-folder housekeeping driven by the Logging-tab preferences. Both steps are
|
||||
/// opt-in (off by default): first prune logs older than the configured age, then warn if the
|
||||
/// folder still exceeds the configured size. Best-effort — failures never block launch.</summary>
|
||||
private void MaybeRunLogHousekeeping()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
AppConfig cfg;
|
||||
try { cfg = AppConfig.Load(); }
|
||||
catch { return; }
|
||||
|
||||
if (cfg.PruneOldLogs)
|
||||
{
|
||||
var removed = LogMaintenance.PruneLogsOlderThan(cfg.PruneOldLogsDays, logFile.Path);
|
||||
if (removed > 0)
|
||||
logFile.Event($"log housekeeping: pruned {removed} log(s) older than {cfg.PruneOldLogsDays} day(s)");
|
||||
}
|
||||
|
||||
if (cfg.WarnIfLogsFolderExceeds)
|
||||
{
|
||||
var bytes = LogMaintenance.LogsFolderSizeBytes();
|
||||
var limitBytes = (long)cfg.LogsFolderWarnThresholdMb * 1024 * 1024;
|
||||
if (bytes > limitBytes)
|
||||
{
|
||||
var mb = bytes / (1024.0 * 1024.0);
|
||||
logFile.Event($"log housekeeping: logs folder {mb:0.#} MB exceeds {cfg.LogsFolderWarnThresholdMb} MB threshold — warning user");
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = "RemSound",
|
||||
Heading = "Logs folder is getting large",
|
||||
Text = $"The RemSound logs folder is using about {mb:0} MB, which is over your {cfg.LogsFolderWarnThresholdMb} MB warning size.\n\n"
|
||||
+ "You can clear old logs from Preferences, on the Logging tab.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
AllowCancel = true,
|
||||
};
|
||||
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
|
||||
catch (Exception ex) { logFile.Event($"log housekeeping: warn dialog failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
||||
/// running version changed since the last launch we recorded, open the About box once so
|
||||
/// they see what changed in the update just installed. Always records the current version
|
||||
@@ -2242,6 +2289,13 @@ public sealed class MainForm : Form
|
||||
UpdateDiagnosticsGate();
|
||||
},
|
||||
writeLogsNow: () => logFile.Event("user requested write logs now"),
|
||||
deleteAllLogs: () =>
|
||||
{
|
||||
// Spare the log we're currently writing (it's held open and can't be removed anyway).
|
||||
var removed = LogMaintenance.DeleteAllLogs(logFile.Path);
|
||||
logFile.Event($"user deleted all logs from Preferences ({removed} file(s) removed)");
|
||||
return removed;
|
||||
},
|
||||
checkForUpdatesNow: () => CheckForUpdatesManually(),
|
||||
onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
|
||||
applyUpnpEnabled: enabled =>
|
||||
@@ -3130,6 +3184,21 @@ public sealed class MainForm : Form
|
||||
statusReadout.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Speak the current connection status line aloud through the active screen reader, via
|
||||
/// Tolk (<see cref="ScreenReader"/>). Answers issue #13: NVDA sometimes can't see the status
|
||||
/// readout ("no status line found"), so this reads it on demand. Triggered only by a user-set
|
||||
/// global hotkey (screen-reader only, unset by default) — being a system-wide hotkey it works from
|
||||
/// anywhere, whether or not RemSound is focused. Newlines become ". " so the multi-line readout
|
||||
/// speaks as a sentence rather than running together.</summary>
|
||||
private void SpeakStatusLine()
|
||||
{
|
||||
var text = statusReadout.Text;
|
||||
text = string.IsNullOrWhiteSpace(text)
|
||||
? "No status information available."
|
||||
: text.Replace("\r\n", ". ").Replace("\n", ". ");
|
||||
ScreenReader.Speak(text);
|
||||
}
|
||||
|
||||
private string ComputeStatusText()
|
||||
{
|
||||
// Compute byte-rates from delta since last sample. First call has no baseline so
|
||||
|
||||
@@ -31,6 +31,10 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
private readonly Action sendSystemVolumeDown;
|
||||
private readonly Action sendSystemMuteToggle;
|
||||
private readonly Action quickProfileSwitch;
|
||||
// Speak the connection status line aloud through the active screen reader (Tolk). Screen-reader
|
||||
// specific (issue #13); unset by default. Global so it reads the status even when RemSound isn't
|
||||
// focused — the case NVDA can't otherwise cover.
|
||||
private readonly Action speakStatusLine;
|
||||
private Form? owner;
|
||||
private HotkeyInfo sendMuteHotkey;
|
||||
private HotkeyInfo receiveMuteHotkey;
|
||||
@@ -45,6 +49,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
private HotkeyInfo systemVolumeDownHotkey;
|
||||
private HotkeyInfo systemMuteToggleHotkey;
|
||||
private HotkeyInfo quickProfileSwitchHotkey;
|
||||
private HotkeyInfo speakStatusLineHotkey;
|
||||
private GlobalHotkey? sendMuteGlobalHotkey;
|
||||
private GlobalHotkey? receiveMuteGlobalHotkey;
|
||||
private GlobalHotkey? trayGlobalHotkey;
|
||||
@@ -58,6 +63,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
private GlobalHotkey? systemVolumeDownGlobalHotkey;
|
||||
private GlobalHotkey? systemMuteToggleGlobalHotkey;
|
||||
private GlobalHotkey? quickProfileSwitchGlobalHotkey;
|
||||
private GlobalHotkey? speakStatusLineGlobalHotkey;
|
||||
|
||||
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
|
||||
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
|
||||
@@ -87,7 +93,8 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
Action sendSystemVolumeUp,
|
||||
Action sendSystemVolumeDown,
|
||||
Action sendSystemMuteToggle,
|
||||
Action quickProfileSwitch)
|
||||
Action quickProfileSwitch,
|
||||
Action speakStatusLine)
|
||||
{
|
||||
this.settingsStore = settingsStore;
|
||||
this.toggleSend = toggleSend;
|
||||
@@ -103,6 +110,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
this.sendSystemVolumeDown = sendSystemVolumeDown;
|
||||
this.sendSystemMuteToggle = sendSystemMuteToggle;
|
||||
this.quickProfileSwitch = quickProfileSwitch;
|
||||
this.speakStatusLine = speakStatusLine;
|
||||
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
|
||||
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
|
||||
trayHotkey = settingsStore.LoadTrayHotkey();
|
||||
@@ -116,6 +124,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
|
||||
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
|
||||
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
|
||||
speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey();
|
||||
}
|
||||
|
||||
public void Initialize(Form ownerForm)
|
||||
@@ -134,6 +143,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
speakStatusLineGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
|
||||
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
|
||||
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
|
||||
@@ -147,6 +157,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
|
||||
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
|
||||
quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch);
|
||||
speakStatusLineGlobalHotkey.Pressed += () => InvokeOnOwner(speakStatusLine);
|
||||
RegisterSendMuteHotkey();
|
||||
RegisterReceiveMuteHotkey();
|
||||
RegisterTrayHotkey();
|
||||
@@ -160,6 +171,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
RegisterSystemVolumeDownHotkey();
|
||||
RegisterSystemMuteToggleHotkey();
|
||||
RegisterQuickProfileSwitchHotkey();
|
||||
RegisterSpeakStatusLineHotkey();
|
||||
}
|
||||
|
||||
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
|
||||
@@ -269,6 +281,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
|
||||
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
|
||||
list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}");
|
||||
list.Items.Add($"Speak the RemSound status information from anywhere (screen reader only): {speakStatusLineHotkey}");
|
||||
if (prev >= 0 && prev < list.Items.Count)
|
||||
{
|
||||
list.SelectedIndex = prev;
|
||||
@@ -306,6 +319,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
|
||||
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
|
||||
case 12: ChangeQuickProfileSwitchHotkey(dialog); break;
|
||||
case 13: ChangeSpeakStatusLineHotkey(dialog); break;
|
||||
default: return;
|
||||
}
|
||||
RefreshList();
|
||||
@@ -338,6 +352,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
case 10: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
|
||||
case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
|
||||
case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break;
|
||||
case 13: ApplyUnset("speak-status-line", h => speakStatusLineHotkey = h, RegisterSpeakStatusLineHotkey, settingsStore.SaveSpeakStatusLineHotkey); break;
|
||||
default: return;
|
||||
}
|
||||
RefreshList();
|
||||
@@ -418,6 +433,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
systemVolumeDownGlobalHotkey?.Dispose();
|
||||
systemMuteToggleGlobalHotkey?.Dispose();
|
||||
quickProfileSwitchGlobalHotkey?.Dispose();
|
||||
speakStatusLineGlobalHotkey?.Dispose();
|
||||
}
|
||||
|
||||
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
|
||||
@@ -433,6 +449,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
|
||||
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
|
||||
public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey;
|
||||
public HotkeyInfo SpeakStatusLineHotkey => speakStatusLineHotkey;
|
||||
|
||||
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
|
||||
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
|
||||
@@ -571,6 +588,13 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
settingsStore.SaveQuickProfileSwitchHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeSpeakStatusLineHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "speak-status-line", h =>
|
||||
{
|
||||
speakStatusLineHotkey = h;
|
||||
RegisterSpeakStatusLineHotkey();
|
||||
settingsStore.SaveSpeakStatusLineHotkey(h);
|
||||
});
|
||||
|
||||
// Hotkeys come in two flavours and need different Windows-side registration:
|
||||
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
|
||||
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
|
||||
@@ -599,6 +623,9 @@ internal sealed class MainFormHotkeyController : IDisposable
|
||||
// Quick profile switch is a one-shot (press → open the popup); MOD_NOREPEAT (the default) keeps
|
||||
// a held key from re-opening it repeatedly.
|
||||
private void RegisterQuickProfileSwitchHotkey() => RegisterIfSet(quickProfileSwitchGlobalHotkey, quickProfileSwitchHotkey, "quick profile switch");
|
||||
// Speak status line is a one-shot (press → read the status aloud once); MOD_NOREPEAT (the default)
|
||||
// keeps a held key from re-triggering the speech over and over.
|
||||
private void RegisterSpeakStatusLineHotkey() => RegisterIfSet(speakStatusLineGlobalHotkey, speakStatusLineHotkey, "speak status line");
|
||||
|
||||
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
|
||||
{
|
||||
|
||||
@@ -293,6 +293,60 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// --- Logging tab: log-folder housekeeping (2026-06-19). All machine-local (AppConfig). The two
|
||||
// spinners are greyed out until their enabling checkbox is ticked. Mnemonics on this tab: L, W,
|
||||
// S, M, D, Y, A — all distinct (tab scope is per-page, so reuse elsewhere is fine). ---
|
||||
private readonly AccessibleCheckBox warnIfLogsExceedBox = new()
|
||||
{
|
||||
Text = "Warn at startup if the logs folder is larger than (Alt+&S)",
|
||||
AccessibleName = "Warn at startup if the logs folder is larger than",
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly NumericUpDown logsSizeLimitBox = new()
|
||||
{
|
||||
Minimum = 1,
|
||||
Maximum = 100000,
|
||||
Increment = 10,
|
||||
Value = 100,
|
||||
Width = 90,
|
||||
AccessibleName = "Warn when the logs folder is larger than this many megabytes (Alt+M)",
|
||||
};
|
||||
private readonly MnemonicLabel logsSizeUnitLabel = new()
|
||||
{
|
||||
Text = "&megabytes",
|
||||
AutoSize = true,
|
||||
Anchor = AnchorStyles.Left,
|
||||
Padding = new Padding(6, 6, 0, 0),
|
||||
};
|
||||
private readonly AccessibleCheckBox pruneOldLogsBox = new()
|
||||
{
|
||||
Text = "Delete logs older than (Alt+&D)",
|
||||
AccessibleName = "Delete logs older than",
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly NumericUpDown pruneDaysBox = new()
|
||||
{
|
||||
Minimum = 1,
|
||||
Maximum = 30,
|
||||
Increment = 1,
|
||||
Value = 14,
|
||||
Width = 70,
|
||||
AccessibleName = "Delete logs older than this many days (Alt+Y)",
|
||||
};
|
||||
private readonly MnemonicLabel pruneDaysUnitLabel = new()
|
||||
{
|
||||
Text = "da&ys old",
|
||||
AutoSize = true,
|
||||
Anchor = AnchorStyles.Left,
|
||||
Padding = new Padding(6, 6, 0, 0),
|
||||
};
|
||||
private readonly Button deleteAllLogsButton = new()
|
||||
{
|
||||
Text = "Delete &all logs",
|
||||
AccessibleName = "Delete all logs",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// Startup behaviour (moved here from the Options-menu StartupBehaviourDialog, 2026-06-13). These
|
||||
// live on their own tab; their Alt-letters are isolated per tab so reusing M/A/P/L is fine.
|
||||
private readonly AccessibleCheckBox startMinimisedBox = new()
|
||||
@@ -353,6 +407,7 @@ internal sealed class PreferencesDialog : Form
|
||||
Func<bool> getLoggingEnabled,
|
||||
Action<bool> applyLoggingEnabled,
|
||||
Action writeLogsNow,
|
||||
Func<int> deleteAllLogs,
|
||||
Action checkForUpdatesNow,
|
||||
Action onUpdateFrequencyChanged,
|
||||
Action<bool> applyUpnpEnabled,
|
||||
@@ -585,6 +640,70 @@ internal sealed class PreferencesDialog : Form
|
||||
|
||||
writeLogsNowButton.Click += (_, _) => writeLogsNow();
|
||||
|
||||
// --- Logging housekeeping (machine-local; each control writes through to AppConfig on
|
||||
// change, like the Update-settings controls above). The two spinners follow their
|
||||
// checkbox's enabled state so they're only editable when the feature is on. ---
|
||||
warnIfLogsExceedBox.Checked = cfgForLoad.WarnIfLogsFolderExceeds;
|
||||
logsSizeLimitBox.Value = Math.Clamp(cfgForLoad.LogsFolderWarnThresholdMb, (int)logsSizeLimitBox.Minimum, (int)logsSizeLimitBox.Maximum);
|
||||
logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
|
||||
logsSizeUnitLabel.MnemonicTarget = logsSizeLimitBox;
|
||||
warnIfLogsExceedBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.WarnIfLogsFolderExceeds = warnIfLogsExceedBox.Checked;
|
||||
TrySaveConfig(cfg);
|
||||
logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
|
||||
};
|
||||
logsSizeLimitBox.ValueChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.LogsFolderWarnThresholdMb = (int)logsSizeLimitBox.Value;
|
||||
TrySaveConfig(cfg);
|
||||
};
|
||||
|
||||
pruneOldLogsBox.Checked = cfgForLoad.PruneOldLogs;
|
||||
pruneDaysBox.Value = Math.Clamp(cfgForLoad.PruneOldLogsDays, (int)pruneDaysBox.Minimum, (int)pruneDaysBox.Maximum);
|
||||
pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
|
||||
pruneDaysUnitLabel.MnemonicTarget = pruneDaysBox;
|
||||
pruneOldLogsBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.PruneOldLogs = pruneOldLogsBox.Checked;
|
||||
TrySaveConfig(cfg);
|
||||
pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
|
||||
};
|
||||
pruneDaysBox.ValueChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.PruneOldLogsDays = (int)pruneDaysBox.Value;
|
||||
TrySaveConfig(cfg);
|
||||
};
|
||||
|
||||
deleteAllLogsButton.Click += (_, _) =>
|
||||
{
|
||||
var confirm = new TaskDialogPage
|
||||
{
|
||||
Caption = "RemSound",
|
||||
Heading = "Delete all logs?",
|
||||
Text = "This permanently deletes every log file in the logs folder except the one currently in use. This cannot be undone.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
Buttons = { TaskDialogButton.Yes, TaskDialogButton.No },
|
||||
DefaultButton = TaskDialogButton.No,
|
||||
AllowCancel = true,
|
||||
};
|
||||
if (TaskDialog.ShowDialog(this, confirm) != TaskDialogButton.Yes) return;
|
||||
var removed = deleteAllLogs();
|
||||
var done = new TaskDialogPage
|
||||
{
|
||||
Caption = "RemSound",
|
||||
Heading = "Logs deleted",
|
||||
Text = removed == 1 ? "Deleted 1 log file." : $"Deleted {removed} log files.",
|
||||
Icon = TaskDialogIcon.Information,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
};
|
||||
TaskDialog.ShowDialog(this, done);
|
||||
};
|
||||
|
||||
closeButton.Click += (_, _) => Close();
|
||||
|
||||
// Wire up the Startup behaviour tab (moved here from the old Options-menu dialog).
|
||||
@@ -650,16 +769,46 @@ internal sealed class PreferencesDialog : Form
|
||||
startupListPanel.Controls.Add(startupProfileListLabel);
|
||||
startupListPanel.Controls.Add(startupProfileList);
|
||||
|
||||
// Four tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
|
||||
// Logging tab rows: each spinner sits with its unit label on its own indented row beneath the
|
||||
// checkbox that enables it (number box first, then the "megabytes"/"days old" unit label —
|
||||
// matching the natural "...larger than [100] megabytes" reading order).
|
||||
var logsSizeRow = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(20, 2, 0, 0),
|
||||
};
|
||||
logsSizeRow.Controls.Add(logsSizeLimitBox);
|
||||
logsSizeRow.Controls.Add(logsSizeUnitLabel);
|
||||
var pruneDaysRow = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(20, 2, 0, 0),
|
||||
};
|
||||
pruneDaysRow.Controls.Add(pruneDaysBox);
|
||||
pruneDaysRow.Controls.Add(pruneDaysUnitLabel);
|
||||
|
||||
// Five tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
|
||||
// strip switch tabs; the active page's controls are the next tab stops. The control itself
|
||||
// is a field (declared above) so OnShown can focus it when the dialog opens.
|
||||
// is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its
|
||||
// own tab (2026-06-19); the two logging controls moved off the General tab to lead it.
|
||||
tabs.TabPages.Add(MakeTab("General",
|
||||
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, loggingBox, writeLogsNowButton));
|
||||
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel));
|
||||
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
|
||||
tabs.TabPages.Add(MakeTab("Startup behaviour",
|
||||
startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel));
|
||||
tabs.TabPages.Add(MakeTab("Update settings",
|
||||
checkForUpdatesOnStartupBox, freqRow, checkForUpdatesNowButton, silentlyInstallUpdatesBox, showWhatsNewAfterUpdateBox));
|
||||
tabs.TabPages.Add(MakeTab("Logging",
|
||||
loggingBox, writeLogsNowButton,
|
||||
warnIfLogsExceedBox, logsSizeRow,
|
||||
pruneOldLogsBox, pruneDaysRow,
|
||||
deleteAllLogsButton));
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
|
||||
@@ -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>4.2</Version>
|
||||
<Version>4.3</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -79,6 +79,17 @@
|
||||
<Link>readme.html</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- Tolk screen-reader output DLLs (vendored 2026-06-19). Native libraries P/Invoked by
|
||||
TolkScreenReaderOutput so RemSound can speak the status line aloud on demand (issue #13) —
|
||||
needed because a GLOBAL hotkey fires while RemSound isn't focused, where NVDA won't read
|
||||
anything itself. Tolk.dll loads its two helper DLLs from the same folder, so all three ship
|
||||
flat next to the exe. Copied on build AND publish; the EnsureTolkDllsPublished target below
|
||||
makes the publish copy marker-independent (the same belt-and-braces the cue WAVs use). -->
|
||||
<Content Include="..\..\tolk\Tolk.dll;..\..\tolk\nvdaControllerClient64.dll;..\..\tolk\SAAPI64.dll;..\..\tolk\LICENSE-Tolk.txt">
|
||||
<Link>%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The Content items above use
|
||||
@@ -97,4 +108,14 @@
|
||||
DestinationFolder="$(PublishDir)default sounds"
|
||||
SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
<!-- Same marker-independent guarantee for the Tolk DLLs: always copy them into a PUBLISHED build
|
||||
so a release zip can never ship without the screen-reader libraries the "speak status line"
|
||||
feature needs. -->
|
||||
<Target Name="EnsureTolkDllsPublished" AfterTargets="Publish">
|
||||
<ItemGroup>
|
||||
<_TolkFiles Include="..\..\tolk\Tolk.dll;..\..\tolk\nvdaControllerClient64.dll;..\..\tolk\SAAPI64.dll;..\..\tolk\LICENSE-Tolk.txt" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_TolkFiles)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide screen-reader speech. Used for feedback the screen reader can't otherwise observe —
|
||||
/// chiefly the "speak the status line" hotkey (GitHub issue #13), which must read aloud even when the
|
||||
/// global hotkey fires while RemSound isn't the focused window.
|
||||
///
|
||||
/// Holds a single backend, created once on first use. Today that is always Tolk, which works on every
|
||||
/// Windows version RemSound supports — including Windows 7. The interface seam exists so a future build
|
||||
/// can choose a different backend per OS without changing any caller: branch inside
|
||||
/// <see cref="CreateBackend"/>. (Prism is the modern successor to Tolk but requires Windows 10+, so it
|
||||
/// can't be the default while Win7 is supported — see <see cref="IScreenReaderOutput"/>.)
|
||||
/// </summary>
|
||||
internal static class ScreenReader
|
||||
{
|
||||
private static readonly object sync = new();
|
||||
private static IScreenReaderOutput? backend;
|
||||
|
||||
private static IScreenReaderOutput Backend
|
||||
{
|
||||
get { lock (sync) { return backend ??= CreateBackend(); } }
|
||||
}
|
||||
|
||||
private static IScreenReaderOutput CreateBackend()
|
||||
{
|
||||
// Win7-safe default. To adopt Prism on Windows 10+ later, branch on Environment.OSVersion
|
||||
// here and return a PrismScreenReaderOutput when the OS is >= Windows 10 — callers below stay
|
||||
// exactly as they are.
|
||||
return new TolkScreenReaderOutput();
|
||||
}
|
||||
|
||||
/// <summary>Speak text through the active screen reader (best-effort; silent if none is running).
|
||||
/// Returns true if it reached a screen reader.</summary>
|
||||
public static bool Speak(string text, bool interrupt = true) => Backend.Speak(text, interrupt);
|
||||
|
||||
/// <summary>Release the backend on app shutdown. Safe to call when nothing was ever spoken.</summary>
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
backend?.Shutdown();
|
||||
backend = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ internal static class SelfTest
|
||||
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
|
||||
("Preferences", () => new PreferencesDialog(
|
||||
new RemSoundSettingsStore("RemSound"), null,
|
||||
() => false, _ => { }, () => { }, () => { }, () => { }, _ => { },
|
||||
() => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { },
|
||||
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
|
||||
_ => { }, _ => { })),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Tolk-backed <see cref="IScreenReaderOutput"/>. Tolk (https://github.com/dkager/tolk, LGPL-3.0) is a
|
||||
/// screen-reader abstraction DLL that auto-detects whichever reader is running (NVDA, JAWS, Window-Eyes,
|
||||
/// SuperNova, System Access, ZoomText) and falls back to SAPI, then routes speech to it. The native
|
||||
/// <c>Tolk.dll</c> plus its helpers (<c>nvdaControllerClient64.dll</c>, <c>SAAPI64.dll</c>) ship flat
|
||||
/// next to the exe (see the .csproj Content items) — Tolk.dll loads those helpers from the same folder.
|
||||
///
|
||||
/// Loaded lazily on the first <see cref="Speak"/> and never re-attempted once it fails, so a missing DLL
|
||||
/// or absent screen reader just means silence, never an exception. All entry points are wrapped in
|
||||
/// try/catch and guarded by a lock, so it's safe to call from any thread (e.g. a hotkey callback).
|
||||
/// </summary>
|
||||
internal sealed class TolkScreenReaderOutput : IScreenReaderOutput
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private bool loadAttempted;
|
||||
private bool loaded;
|
||||
|
||||
public bool Speak(string text, bool interrupt = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||
if (!EnsureLoaded()) return false;
|
||||
try { return Tolk_Output(text, interrupt); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!loaded) return;
|
||||
try { Tolk_Unload(); } catch { /* best-effort */ }
|
||||
loaded = false;
|
||||
loadAttempted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Load Tolk once. Succeeds only if Tolk loads AND reports a working speech channel, so a
|
||||
/// machine with the DLLs present but no screen reader running stays silent instead of half-init'd.</summary>
|
||||
private bool EnsureLoaded()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (loaded) return true;
|
||||
if (loadAttempted) return false; // already tried and failed — don't spam load attempts
|
||||
loadAttempted = true;
|
||||
try { loaded = Tolk_Load() && Tolk_HasSpeech(); }
|
||||
catch { loaded = false; }
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Tolk_Load();
|
||||
|
||||
[DllImport("Tolk.dll")]
|
||||
private static extern void Tolk_Unload();
|
||||
|
||||
[DllImport("Tolk.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Tolk_HasSpeech();
|
||||
|
||||
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Tolk_Output(string text, [MarshalAs(UnmanagedType.Bool)] bool interrupt);
|
||||
}
|
||||
@@ -128,6 +128,25 @@ public sealed class AppConfig
|
||||
/// ticks <em>Enable logs</em> in the Preferences dialog.</summary>
|
||||
public bool LoggingEnabled { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound checks the total size of the <c>logs\</c> folder at startup and,
|
||||
/// when it exceeds <see cref="LogsFolderWarnThresholdMb"/> megabytes, shows a one-time warning so
|
||||
/// the user can prune or clear it. Off by default (opt-in, like the other behaviour toggles).
|
||||
/// Machine-local — a "watch my disk on this box" decision, not a per-profile audio setting.</summary>
|
||||
public bool WarnIfLogsFolderExceeds { get; set; }
|
||||
|
||||
/// <summary>The size threshold in megabytes for <see cref="WarnIfLogsFolderExceeds"/>. Default
|
||||
/// 100 MB. Only consulted when that flag is on.</summary>
|
||||
public int LogsFolderWarnThresholdMb { get; set; } = 100;
|
||||
|
||||
/// <summary>If true, RemSound deletes log files older than <see cref="PruneOldLogsDays"/> days
|
||||
/// from the <c>logs\</c> folder at startup. Off by default (opt-in). The currently-open log file
|
||||
/// is never a candidate (it's today's). Machine-local.</summary>
|
||||
public bool PruneOldLogs { get; set; }
|
||||
|
||||
/// <summary>Age in days for <see cref="PruneOldLogs"/>: log files last written more than this many
|
||||
/// days ago are deleted at startup. Range 1–30, default 14. Only consulted when that flag is on.</summary>
|
||||
public int PruneOldLogsDays { get; set; } = 14;
|
||||
|
||||
/// <summary>If non-null and a profile with this title exists, RemSound skips the
|
||||
/// startup profile picker and loads this profile directly. Combine with
|
||||
/// <see cref="StartMinimised"/> + the Windows auto-start registry entry
|
||||
|
||||
@@ -212,6 +212,9 @@ public sealed class Profile
|
||||
/// <summary>Global hotkey that opens the Quick profile switch popup — a list of all profiles you
|
||||
/// can arrow through and press Enter to switch to, from anywhere in Windows. Unset by default.</summary>
|
||||
public HotkeyRecord? QuickProfileSwitchHotkey { get; set; }
|
||||
/// <summary>Optional global hotkey that speaks the connection status line aloud through the active
|
||||
/// screen reader (issue #13). Unset by default. Screen-reader specific.</summary>
|
||||
public HotkeyRecord? SpeakStatusLineHotkey { get; set; }
|
||||
/// <summary>When true, this machine honours incoming Control packets from connected
|
||||
/// peers — adjusts the local volume slider or toggles mute. Default false: receiving
|
||||
/// remote control is opt-in even though the audio allow-list already gates who's
|
||||
|
||||
@@ -144,6 +144,16 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public HotkeyInfo LoadSpeakStatusLineHotkey() =>
|
||||
Try(() => Load()?.SpeakStatusLineHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
|
||||
|
||||
public void SaveSpeakStatusLineHotkey(HotkeyInfo hotkey)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.SpeakStatusLineHotkey = HotkeySetting.From(hotkey);
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
|
||||
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
|
||||
|
||||
@@ -593,6 +603,7 @@ public sealed class RemSoundSettingsStore
|
||||
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
|
||||
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
|
||||
QuickProfileSwitchHotkey = profile.QuickProfileSwitchHotkey is null ? null : HotkeySettingFromRecord(profile.QuickProfileSwitchHotkey),
|
||||
SpeakStatusLineHotkey = profile.SpeakStatusLineHotkey is null ? null : HotkeySettingFromRecord(profile.SpeakStatusLineHotkey),
|
||||
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
|
||||
MaxLatencyMs = profile.MaxLatencyMs,
|
||||
Codec = profile.Codec,
|
||||
@@ -649,6 +660,7 @@ public sealed class RemSoundSettingsStore
|
||||
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
|
||||
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
|
||||
profile.QuickProfileSwitchHotkey = s.QuickProfileSwitchHotkey is null ? null : HotkeyRecordFromSetting(s.QuickProfileSwitchHotkey);
|
||||
profile.SpeakStatusLineHotkey = s.SpeakStatusLineHotkey is null ? null : HotkeyRecordFromSetting(s.SpeakStatusLineHotkey);
|
||||
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
|
||||
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
|
||||
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
|
||||
@@ -716,6 +728,7 @@ public sealed class RemSoundSettingsStore
|
||||
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
|
||||
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
|
||||
public HotkeySetting? QuickProfileSwitchHotkey { get; set; }
|
||||
public HotkeySetting? SpeakStatusLineHotkey { get; set; }
|
||||
public bool? AcceptRemoteVolumeCommands { get; set; }
|
||||
public int? MaxLatencyMs { get; set; }
|
||||
public AudioTransportCodec? Codec { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user