2026-07-06 10:05:00 +01:00
|
|
|
using System.Net;
|
2026-05-15 12:40:01 +01:00
|
|
|
using RemSound.Core;
|
|
|
|
|
using RemSound.Receiver;
|
|
|
|
|
using RemSound.Sender;
|
|
|
|
|
|
|
|
|
|
namespace RemSound.App;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Glue between MainForm's Record menu and the actual recording pipeline. Owns the
|
|
|
|
|
/// lifecycle of the currently-running <see cref="AudioRecorder"/> (if any) and wires
|
|
|
|
|
/// the sender / receiver taps to it. Reading the user's saved settings, persisting
|
|
|
|
|
/// changes after the settings dialog, opening / changing the recordings folder — all
|
|
|
|
|
/// flow through here so MainForm stays focused on UI wiring.
|
|
|
|
|
///
|
|
|
|
|
/// Threading: the public methods are called from the UI thread only. The recorder
|
|
|
|
|
/// itself runs on its own background thread (it owns a queue + writer); the controller
|
|
|
|
|
/// just constructs and disposes it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal sealed class RecordingController
|
|
|
|
|
{
|
|
|
|
|
private readonly AudioSender sender;
|
|
|
|
|
private readonly AudioReceiver receiver;
|
|
|
|
|
private readonly RemSoundSettingsStore settings;
|
|
|
|
|
private readonly Action<string> diagnostic;
|
|
|
|
|
private AudioRecorder? active;
|
2026-07-06 10:39:09 +01:00
|
|
|
// Multi-track (split) state — one track per connected peer (keyed by address), plus one for your own
|
|
|
|
|
// send. Built entirely at Start and only replaced with null at Stop, so the audio-thread tap can read
|
|
|
|
|
// the dictionary with a plain volatile read. Each PeerTrack sums that peer's stream(s) per render and
|
|
|
|
|
// flushes once, so a peer sending on more than one lane is SUMMED (recorded as it sounds), not stacked.
|
|
|
|
|
private volatile Dictionary<string, PeerTrack>? peerTracks;
|
2026-07-06 10:05:00 +01:00
|
|
|
private AudioRecorder? meRecorder;
|
2026-07-06 10:39:09 +01:00
|
|
|
|
|
|
|
|
private sealed class PeerTrack(AudioRecorder recorder)
|
|
|
|
|
{
|
|
|
|
|
public AudioRecorder Recorder { get; } = recorder;
|
|
|
|
|
public float[] Accum = new float[4096];
|
|
|
|
|
public int Len;
|
|
|
|
|
}
|
2026-07-06 10:05:00 +01:00
|
|
|
// Single-file BYPASS state — sum each peer's RAW block per render, flush on the block boundary.
|
|
|
|
|
private float[] rawMixAccum = new float[4096];
|
|
|
|
|
private int rawMixLen;
|
|
|
|
|
|
|
|
|
|
/// <summary>Supplies the currently-connected peers (address + display name) at the moment a split
|
|
|
|
|
/// recording starts, so one track file is created per peer. Set by MainForm.</summary>
|
|
|
|
|
public Func<IReadOnlyList<(IPAddress Address, string Name)>>? ConnectedPeersProvider { get; set; }
|
2026-05-15 12:40:01 +01:00
|
|
|
|
|
|
|
|
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
|
|
|
|
|
{
|
|
|
|
|
this.sender = sender;
|
|
|
|
|
this.receiver = receiver;
|
|
|
|
|
this.settings = settings;
|
|
|
|
|
this.diagnostic = diagnostic;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:39:09 +01:00
|
|
|
public bool IsRecording => active is not null || meRecorder is not null || peerTracks is not null;
|
2026-05-15 12:40:01 +01:00
|
|
|
|
2026-05-28 14:24:34 +01:00
|
|
|
/// <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; }
|
|
|
|
|
|
2026-05-15 12:40:01 +01:00
|
|
|
/// <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>
|
|
|
|
|
public event Action<bool>? RecordingStateChanged;
|
|
|
|
|
|
|
|
|
|
/// <summary>Start a new recording using the currently-saved profile settings. If a
|
|
|
|
|
/// recording is already running this is a no-op (the menu shouldn't ever offer Start
|
|
|
|
|
/// while recording, but the guard is here for safety).</summary>
|
|
|
|
|
public void Start()
|
|
|
|
|
{
|
2026-07-06 10:05:00 +01:00
|
|
|
if (IsRecording) return;
|
2026-05-15 12:40:01 +01:00
|
|
|
var s = settings.LoadRecordingSettings();
|
2026-07-06 10:05:00 +01:00
|
|
|
var now = DateTime.Now;
|
2026-05-15 12:40:01 +01:00
|
|
|
try
|
|
|
|
|
{
|
2026-07-06 10:05:00 +01:00
|
|
|
if (s.SplitTracks) StartMultiTrack(s, now);
|
|
|
|
|
else StartSingleTrack(s, now);
|
2026-05-15 12:40:01 +01:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
|
2026-07-06 10:05:00 +01:00
|
|
|
CleanUpAfterFailedStart();
|
2026-05-15 12:40:01 +01:00
|
|
|
MessageBox.Show(
|
|
|
|
|
$"Could not start recording:\n\n{ex.Message}",
|
|
|
|
|
"RemSound — recording",
|
|
|
|
|
MessageBoxButtons.OK,
|
|
|
|
|
MessageBoxIcon.Warning);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-28 14:24:34 +01:00
|
|
|
RecordingStartedUtc = DateTime.UtcNow;
|
2026-05-15 12:40:01 +01:00
|
|
|
RecordingStateChanged?.Invoke(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Stop the currently-running recording. Unhooks taps, flushes the writer
|
|
|
|
|
/// queue, closes the file, and surfaces the resulting path in a brief MessageBox
|
|
|
|
|
/// so the user knows where the file landed.</summary>
|
|
|
|
|
public void Stop()
|
|
|
|
|
{
|
2026-07-06 10:05:00 +01:00
|
|
|
if (!IsRecording) return;
|
2026-05-15 12:40:01 +01:00
|
|
|
|
2026-07-06 10:05:00 +01:00
|
|
|
// Unhook every tap FIRST so no more audio is queued during the drain.
|
2026-05-15 12:40:01 +01:00
|
|
|
sender.OnSentSamples = null;
|
|
|
|
|
receiver.OnReceivedSamples = null;
|
2026-07-06 10:05:00 +01:00
|
|
|
receiver.SetPeerRecordTap(null, false);
|
|
|
|
|
receiver.OnRecordBlockComplete = null;
|
2026-05-15 12:40:01 +01:00
|
|
|
|
2026-07-06 10:05:00 +01:00
|
|
|
var single = active;
|
|
|
|
|
var me = meRecorder;
|
2026-07-06 10:39:09 +01:00
|
|
|
var tracks = peerTracks;
|
2026-05-15 12:40:01 +01:00
|
|
|
active = null;
|
2026-07-06 10:05:00 +01:00
|
|
|
meRecorder = null;
|
2026-07-06 10:39:09 +01:00
|
|
|
peerTracks = null;
|
2026-07-06 10:05:00 +01:00
|
|
|
rawMixLen = 0;
|
2026-05-28 14:24:34 +01:00
|
|
|
RecordingStartedUtc = null;
|
2026-07-06 10:05:00 +01:00
|
|
|
|
|
|
|
|
StopRecorder(single);
|
|
|
|
|
StopRecorder(me);
|
2026-07-06 10:39:09 +01:00
|
|
|
if (tracks is not null) foreach (var t in tracks.Values) StopRecorder(t.Recorder);
|
2026-07-06 10:05:00 +01:00
|
|
|
|
2026-05-15 12:40:01 +01:00
|
|
|
RecordingStateChanged?.Invoke(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnRecorderFinished(string path, long bytes)
|
|
|
|
|
{
|
|
|
|
|
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:05:00 +01:00
|
|
|
private void StartSingleTrack(RecordingSettings s, DateTime now)
|
|
|
|
|
{
|
|
|
|
|
var path = SingleTrackPath(s, now);
|
|
|
|
|
active = new AudioRecorder(s, diagnostic, OnRecorderFinished, path);
|
|
|
|
|
sender.OnSentSamples = active.WriteSent;
|
|
|
|
|
if (s.BypassShaping)
|
|
|
|
|
{
|
|
|
|
|
// Raw single file: the mixed receive tap is POST pan/EQ, so instead sum each peer's RAW
|
|
|
|
|
// block per render and flush it to the one recorder on the block boundary.
|
|
|
|
|
rawMixLen = 0;
|
|
|
|
|
receiver.SetPeerRecordTap(OnRawMixTap, raw: true);
|
|
|
|
|
receiver.OnRecordBlockComplete = FlushRawMix;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
receiver.OnReceivedSamples = active.WriteReceived; // the shaped mix — what you hear
|
|
|
|
|
}
|
|
|
|
|
diagnostic($"recording: started → {path} (source={s.Source}, format={s.FileFormat}, bypass={s.BypassShaping})");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void StartMultiTrack(RecordingSettings s, DateTime now)
|
|
|
|
|
{
|
2026-07-09 20:54:53 +01:00
|
|
|
// Refuse a split received-only recording with no peers connected: it would make an empty dated
|
|
|
|
|
// folder and capture nothing while still announcing "recording started". Fail clearly instead
|
|
|
|
|
// (Start()'s catch surfaces this message). Both / sent-only still record your own send, so they
|
|
|
|
|
// never hit this. Checked BEFORE creating the folder so no orphan folder is left behind.
|
|
|
|
|
if (s.Source == RecordingSource.ReceivedOnly && (ConnectedPeersProvider?.Invoke() ?? []).Count == 0)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
"No peers are connected, so a split received-only recording would capture nothing. " +
|
|
|
|
|
"Connect to a peer first, or set the recording source to include your own sent audio.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:05:00 +01:00
|
|
|
var folder = MultiTrackFolder(s, now);
|
|
|
|
|
Directory.CreateDirectory(folder);
|
|
|
|
|
var ext = AudioRecorder.ExtensionFor(s.FileFormat);
|
|
|
|
|
var time = now.ToString("HH-mm-ss");
|
|
|
|
|
|
2026-07-07 20:10:39 +01:00
|
|
|
// Multi-track follows the Source setting like single-file recording does: peer (received)
|
|
|
|
|
// tracks unless "sent only", and your own (sent) track only when you're actually recording
|
|
|
|
|
// your send ("both" or "sent only") — so a receive-only recording no longer makes a "me" file.
|
|
|
|
|
|
2026-07-06 10:05:00 +01:00
|
|
|
// One track per connected peer (their received audio only), created up front on the UI thread
|
|
|
|
|
// so the audio-thread tap never has to open a file. Peers that join mid-recording aren't added.
|
2026-07-07 20:10:39 +01:00
|
|
|
if (s.Source != RecordingSource.SentOnly)
|
2026-07-06 10:05:00 +01:00
|
|
|
{
|
2026-07-07 20:10:39 +01:00
|
|
|
var recs = new Dictionary<string, PeerTrack>();
|
|
|
|
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
var byAddr = new Dictionary<string, string>();
|
|
|
|
|
foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name;
|
|
|
|
|
foreach (var (addrKey, name) in byAddr)
|
|
|
|
|
{
|
|
|
|
|
var baseName = Sanitize(string.IsNullOrWhiteSpace(name) ? addrKey : name);
|
|
|
|
|
var fileName = $"{baseName} {time}";
|
|
|
|
|
if (!usedNames.Add(fileName)) fileName = $"{baseName} ({addrKey}) {time}";
|
|
|
|
|
var path = Path.Combine(folder, $"{fileName}.{ext}");
|
|
|
|
|
recs[addrKey] = new PeerTrack(new AudioRecorder(WithSource(s, RecordingSource.ReceivedOnly), diagnostic, OnRecorderFinished, path));
|
|
|
|
|
}
|
|
|
|
|
peerTracks = recs;
|
|
|
|
|
receiver.SetPeerRecordTap(OnPeerRecordBlock, raw: s.BypassShaping);
|
|
|
|
|
receiver.OnRecordBlockComplete = FlushPeerTracks;
|
2026-07-06 10:05:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Your own track — your sent audio.
|
2026-07-07 20:10:39 +01:00
|
|
|
if (s.Source != RecordingSource.ReceivedOnly)
|
|
|
|
|
{
|
|
|
|
|
var mePath = Path.Combine(folder, $"{Sanitize(Environment.MachineName)} {time}.{ext}");
|
|
|
|
|
meRecorder = new AudioRecorder(WithSource(s, RecordingSource.SentOnly), diagnostic, OnRecorderFinished, mePath);
|
|
|
|
|
sender.OnSentSamples = meRecorder.WriteSent;
|
|
|
|
|
}
|
2026-07-06 10:05:00 +01:00
|
|
|
|
2026-07-07 20:10:39 +01:00
|
|
|
diagnostic($"recording: started multi-track → {folder} ({(peerTracks?.Count ?? 0)} peer track(s){(meRecorder is not null ? " + your send" : "")}, source={s.Source}, format={s.FileFormat}, bypass={s.BypassShaping})");
|
2026-07-06 10:05:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audio thread. Route each peer's block to that peer's recorder. peerRecorders is fully built at
|
|
|
|
|
// Start and only replaced with null at Stop, so a plain volatile read is safe.
|
|
|
|
|
private void OnPeerRecordBlock(IPEndPoint peer, ReadOnlyMemory<float> block)
|
|
|
|
|
{
|
2026-07-06 10:39:09 +01:00
|
|
|
var tracks = peerTracks;
|
|
|
|
|
if (tracks is null || !tracks.TryGetValue(peer.Address.ToString(), out var t)) return;
|
|
|
|
|
// SUM this peer's block into their per-render buffer, so a peer sending on more than one lane is
|
|
|
|
|
// combined (recorded as it sounds) rather than the lanes stacking one after another.
|
|
|
|
|
var span = block.Span;
|
|
|
|
|
if (t.Accum.Length < span.Length) t.Accum = new float[span.Length];
|
|
|
|
|
for (int i = 0; i < span.Length; i++) t.Accum[i] += span[i];
|
|
|
|
|
t.Len = span.Length;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 18:19:01 +01:00
|
|
|
// Audio thread. Once per render, flush each peer's summed block to their file. Every peer track gets
|
|
|
|
|
// EXACTLY one render block per callback: a peer that produced nothing this block — because it went
|
|
|
|
|
// quiet and its session was dropped, or it disconnected — is padded with silence to the same length.
|
|
|
|
|
// That keeps every track sample-locked to the single render clock, so the tracks can never drift
|
|
|
|
|
// apart from each other however long the recording runs, and they all end up the same length.
|
|
|
|
|
private void FlushPeerTracks(int floats)
|
2026-07-06 10:39:09 +01:00
|
|
|
{
|
|
|
|
|
var tracks = peerTracks;
|
2026-07-08 18:19:01 +01:00
|
|
|
if (tracks is null || floats <= 0) return;
|
2026-07-06 10:39:09 +01:00
|
|
|
foreach (var t in tracks.Values)
|
|
|
|
|
{
|
2026-07-08 18:19:01 +01:00
|
|
|
if (t.Accum.Length < floats) { var a = t.Accum; Array.Resize(ref a, floats); t.Accum = a; }
|
|
|
|
|
for (int i = t.Len; i < floats; i++) t.Accum[i] = 0f; // pad an empty/short block with silence
|
|
|
|
|
t.Recorder.WriteReceived(t.Accum.AsMemory(0, floats), RenderRoute.Mixed);
|
|
|
|
|
Array.Clear(t.Accum, 0, floats);
|
2026-07-06 10:39:09 +01:00
|
|
|
t.Len = 0;
|
|
|
|
|
}
|
2026-07-06 10:05:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audio thread. Sum a peer's raw block into the per-render mix for a single-file bypass recording.
|
|
|
|
|
private void OnRawMixTap(IPEndPoint peer, ReadOnlyMemory<float> block)
|
|
|
|
|
{
|
|
|
|
|
var span = block.Span;
|
|
|
|
|
if (rawMixAccum.Length < span.Length) rawMixAccum = new float[span.Length];
|
|
|
|
|
for (int i = 0; i < span.Length; i++) rawMixAccum[i] += span[i];
|
|
|
|
|
rawMixLen = span.Length;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 18:19:01 +01:00
|
|
|
// Audio thread. Flush the summed raw mix for this render to the single recorder, padding a silent
|
|
|
|
|
// block so the file stays aligned to real time even through total silence, then reset.
|
|
|
|
|
private void FlushRawMix(int floats)
|
2026-07-06 10:05:00 +01:00
|
|
|
{
|
|
|
|
|
var rec = active;
|
2026-07-08 18:19:01 +01:00
|
|
|
if (rec is null || floats <= 0) return;
|
|
|
|
|
if (rawMixAccum.Length < floats) { var a = rawMixAccum; Array.Resize(ref a, floats); rawMixAccum = a; }
|
|
|
|
|
for (int i = rawMixLen; i < floats; i++) rawMixAccum[i] = 0f;
|
|
|
|
|
rec.WriteReceived(rawMixAccum.AsMemory(0, floats), RenderRoute.Mixed);
|
|
|
|
|
Array.Clear(rawMixAccum, 0, floats);
|
|
|
|
|
rawMixLen = 0;
|
2026-07-06 10:05:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void StopRecorder(AudioRecorder? r)
|
|
|
|
|
{
|
|
|
|
|
if (r is null) return;
|
|
|
|
|
try { r.Stop(); r.Dispose(); }
|
|
|
|
|
catch (Exception ex) { diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}"); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void CleanUpAfterFailedStart()
|
|
|
|
|
{
|
|
|
|
|
sender.OnSentSamples = null;
|
|
|
|
|
receiver.OnReceivedSamples = null;
|
|
|
|
|
receiver.SetPeerRecordTap(null, false);
|
|
|
|
|
receiver.OnRecordBlockComplete = null;
|
|
|
|
|
StopRecorder(active); active = null;
|
|
|
|
|
StopRecorder(meRecorder); meRecorder = null;
|
2026-07-06 10:39:09 +01:00
|
|
|
if (peerTracks is not null) { foreach (var t in peerTracks.Values) StopRecorder(t.Recorder); peerTracks = null; }
|
2026-07-06 10:05:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string BaseFolder(RecordingSettings s)
|
|
|
|
|
{
|
|
|
|
|
var f = s.ResolvedFolder();
|
|
|
|
|
return string.IsNullOrWhiteSpace(f) ? RecordingSettings.DefaultFolder() : f;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string DateFolder(RecordingSettings s, DateTime now) =>
|
|
|
|
|
Path.Combine(BaseFolder(s), now.ToString("yyyy-MM-dd"));
|
|
|
|
|
|
|
|
|
|
private static string SingleTrackPath(RecordingSettings s, DateTime now) =>
|
2026-07-07 20:36:17 +01:00
|
|
|
Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording {Sanitize(Environment.MachineName)}.{AudioRecorder.ExtensionFor(s.FileFormat)}");
|
2026-07-06 10:05:00 +01:00
|
|
|
|
|
|
|
|
private static string MultiTrackFolder(RecordingSettings s, DateTime now) =>
|
|
|
|
|
Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording multi track");
|
|
|
|
|
|
|
|
|
|
private static RecordingSettings WithSource(RecordingSettings s, RecordingSource src)
|
|
|
|
|
{
|
|
|
|
|
var c = s.Clone();
|
|
|
|
|
c.Source = src;
|
|
|
|
|
return c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string Sanitize(string name)
|
|
|
|
|
{
|
|
|
|
|
var invalid = Path.GetInvalidFileNameChars();
|
|
|
|
|
var sb = new System.Text.StringBuilder(name.Length);
|
|
|
|
|
foreach (var ch in name) sb.Append(Array.IndexOf(invalid, ch) >= 0 ? '_' : ch);
|
|
|
|
|
var r = sb.ToString().Trim().TrimEnd('.').Trim();
|
|
|
|
|
return string.IsNullOrEmpty(r) ? "peer" : r;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 12:40:01 +01:00
|
|
|
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
|
|
|
|
|
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
|
|
|
|
|
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
|
|
|
|
|
/// rather than swallowing them silently.</summary>
|
|
|
|
|
public void OpenCurrentFolder(IWin32Window? owner)
|
|
|
|
|
{
|
|
|
|
|
var s = settings.LoadRecordingSettings();
|
|
|
|
|
var folder = s.ResolvedFolder();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Directory.CreateDirectory(folder);
|
|
|
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
|
|
|
{
|
|
|
|
|
FileName = folder,
|
|
|
|
|
UseShellExecute = true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
diagnostic($"recording: open folder failed: {ex.GetType().Name}: {ex.Message}");
|
|
|
|
|
MessageBox.Show(owner,
|
|
|
|
|
$"Could not open recordings folder:\n\n{ex.Message}",
|
|
|
|
|
"RemSound — recordings folder",
|
|
|
|
|
MessageBoxButtons.OK,
|
|
|
|
|
MessageBoxIcon.Warning);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Show a folder-picker rooted at the current recordings folder. If the
|
|
|
|
|
/// user picks a different folder, save it on the profile and return true so the
|
|
|
|
|
/// caller can flag the profile dirty.</summary>
|
|
|
|
|
public bool ChangeFolder(IWin32Window? owner)
|
|
|
|
|
{
|
|
|
|
|
var s = settings.LoadRecordingSettings();
|
|
|
|
|
var startFolder = s.ResolvedFolder();
|
|
|
|
|
using var picker = new FolderBrowserDialog
|
|
|
|
|
{
|
|
|
|
|
Description = "Choose a folder for RemSound recordings",
|
|
|
|
|
UseDescriptionForTitle = true,
|
|
|
|
|
SelectedPath = Directory.Exists(startFolder) ? startFolder : RecordingSettings.DefaultFolder(),
|
|
|
|
|
ShowNewFolderButton = true,
|
|
|
|
|
};
|
|
|
|
|
if (picker.ShowDialog(owner) != DialogResult.OK) return false;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return false;
|
|
|
|
|
if (string.Equals(picker.SelectedPath, startFolder, StringComparison.OrdinalIgnoreCase)) return false;
|
|
|
|
|
|
|
|
|
|
s.Folder = picker.SelectedPath;
|
|
|
|
|
settings.SaveRecordingSettings(s);
|
|
|
|
|
diagnostic($"recording: folder changed → {picker.SelectedPath}");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|