Public Access
feat: add transcription pause controls
PR and Push Build/Test / build-and-test (push) Successful in 13m19s
PR and Push Build/Test / build-and-test (push) Successful in 13m19s
This commit is contained in:
@@ -165,6 +165,8 @@ public sealed class RecordingInactivitySafeguardOptions
|
||||
|
||||
public TimeSpan AutoStopAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
public TimeSpan MaximumPauseDuration { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
public TimeSpan InferredEndPadding { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
@@ -6,6 +6,8 @@ public interface IMeetingInactivityPromptService
|
||||
MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task DismissAllAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingInactivityPromptRequest(
|
||||
@@ -18,9 +20,32 @@ public enum MeetingInactivityPromptResponse
|
||||
{
|
||||
Dismissed,
|
||||
Continue,
|
||||
Pause,
|
||||
Stop
|
||||
}
|
||||
|
||||
internal sealed record MeetingInactivityPromptAction(
|
||||
string Content,
|
||||
string ResponseArgument,
|
||||
MeetingInactivityPromptResponse Response);
|
||||
|
||||
internal static class MeetingInactivityPromptActions
|
||||
{
|
||||
public static IReadOnlyList<MeetingInactivityPromptAction> All { get; } =
|
||||
[
|
||||
new("Yes", "stop", MeetingInactivityPromptResponse.Stop),
|
||||
new("No", "continue", MeetingInactivityPromptResponse.Continue),
|
||||
new("Pause transcription", "pause", MeetingInactivityPromptResponse.Pause)
|
||||
];
|
||||
|
||||
public static MeetingInactivityPromptResponse ParseResponse(string? value)
|
||||
{
|
||||
return All.FirstOrDefault(action =>
|
||||
action.ResponseArgument.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
?.Response ?? MeetingInactivityPromptResponse.Continue;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||
{
|
||||
public Task ShowStopPromptAsync(
|
||||
@@ -30,4 +55,9 @@ public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromp
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentArtifacts?.AssistantContextPath,
|
||||
currentArtifacts?.SummaryPath,
|
||||
GetProcessState(currentRun),
|
||||
currentRun?.LaunchProfileName);
|
||||
currentRun?.LaunchProfileName,
|
||||
IsRecording && currentRun?.IsTranscriptionPaused == true);
|
||||
|
||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||
|
||||
@@ -292,7 +293,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker,
|
||||
logger);
|
||||
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
||||
if (runOptions.Recording.InactivitySafeguard.Enabled)
|
||||
if (ShouldRunInactivitySafeguard(runOptions.Recording.InactivitySafeguard))
|
||||
{
|
||||
_ = Task.Run(
|
||||
() => RunInactivitySafeguardAsync(run),
|
||||
@@ -334,11 +335,14 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await StopAsync(null, cancellationToken);
|
||||
return await StopAsync(null, null, null, null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<RecordingStatus> StopAsync(
|
||||
DateTimeOffset? inferredEndTime,
|
||||
RecordingRun? expectedRun,
|
||||
long? expectedActivityVersion,
|
||||
DateTimeOffset? expectedPauseStartedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RecordingRun run;
|
||||
@@ -347,13 +351,30 @@ public sealed class MeetingRecordingCoordinator
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (currentRun is null || currentRun.IsCaptureStopping)
|
||||
if (currentRun is null ||
|
||||
currentRun.IsCaptureStopping ||
|
||||
expectedRun is not null && !ReferenceEquals(currentRun, expectedRun))
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
run = currentRun;
|
||||
if (inferredEndTime is not null)
|
||||
var inactivityStopClaimed = expectedActivityVersion is not null;
|
||||
if (expectedActivityVersion is not null &&
|
||||
(inferredEndTime is null ||
|
||||
!run.TryClaimInactivityStop(expectedActivityVersion.Value, inferredEndTime.Value)))
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
var maximumPauseStopClaimed = expectedPauseStartedAt is not null;
|
||||
if (expectedPauseStartedAt is not null &&
|
||||
!run.TryClaimMaximumPauseStop(expectedPauseStartedAt.Value))
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
if (!inactivityStopClaimed && !maximumPauseStopClaimed && inferredEndTime is not null)
|
||||
{
|
||||
run.SetInferredEndTime(inferredEndTime.Value);
|
||||
}
|
||||
@@ -363,7 +384,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
run.Abort();
|
||||
}
|
||||
else
|
||||
else if (!inactivityStopClaimed && !maximumPauseStopClaimed)
|
||||
{
|
||||
run.StopCapture();
|
||||
}
|
||||
@@ -409,6 +430,65 @@ public sealed class MeetingRecordingCoordinator
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> SetTranscriptionPausedAsync(
|
||||
bool isPaused,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await SetTranscriptionPausedAsync(isPaused, null, null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<RecordingStatus> SetTranscriptionPausedAsync(
|
||||
bool isPaused,
|
||||
RecordingRun? expectedRun,
|
||||
long? expectedActivityVersion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var run = currentRun;
|
||||
if (run is null ||
|
||||
run.IsCaptureStopping ||
|
||||
expectedRun is not null && !ReferenceEquals(run, expectedRun))
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
await run.PipelineGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var changedAt = inactivityClock.Now;
|
||||
changed = run.TrySetTranscriptionPaused(
|
||||
isPaused,
|
||||
changedAt,
|
||||
expectedActivityVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
run.PipelineGate.Release();
|
||||
}
|
||||
|
||||
if (changed && isPaused)
|
||||
{
|
||||
await DismissInactivityPromptsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Meeting transcription {PauseState}",
|
||||
isPaused ? "paused" : "unpaused");
|
||||
}
|
||||
|
||||
return CurrentStatus;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RecordingRun run;
|
||||
@@ -661,8 +741,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
chunk.Channels);
|
||||
}
|
||||
|
||||
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
|
||||
await run.WriteAudioAsync(chunk, run.CaptureCancellation);
|
||||
await run.RouteCapturedAudioAsync(chunk, run.CaptureCancellation);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -701,7 +780,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
run.ResetSpeakerIdentification();
|
||||
}
|
||||
|
||||
run.RecordTranscriptActivity(segment, inactivityClock.Now);
|
||||
var sample = run.TryAddSpeakerSample(segment);
|
||||
if (sample is not null)
|
||||
{
|
||||
@@ -718,6 +796,47 @@ public sealed class MeetingRecordingCoordinator
|
||||
var relabeledSegment = run.Relabel(segment);
|
||||
run.AddLiveSegment(relabeledSegment);
|
||||
await AppendTranscriptSegmentAsync(run, relabeledSegment, cancellationToken);
|
||||
run.RecordTranscriptActivity(segment, inactivityClock.Now);
|
||||
if (!string.IsNullOrWhiteSpace(segment.Text))
|
||||
{
|
||||
await DismissInactivityPromptsForRunAsync(run, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DismissInactivityPromptsForRunAsync(
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(currentRun, run) || run.IsCaptureStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DismissInactivityPromptsAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DismissInactivityPromptsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await inactivityPromptService.DismissAllAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to dismiss transcript inactivity prompts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,7 +964,10 @@ public sealed class MeetingRecordingCoordinator
|
||||
private async Task RunInactivitySafeguardAsync(RecordingRun run)
|
||||
{
|
||||
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
|
||||
var promptThresholds = GetInactivityPromptThresholds(safeguardOptions);
|
||||
var promptThresholds = safeguardOptions.Enabled
|
||||
? GetInactivityPromptThresholds(safeguardOptions)
|
||||
: [];
|
||||
var checkInterval = GetInactivitySafeguardCheckInterval(safeguardOptions);
|
||||
var promptedThresholds = new HashSet<TimeSpan>();
|
||||
var lastActivityVersion = run.GetTranscriptActivitySnapshot().ActivityVersion;
|
||||
|
||||
@@ -853,7 +975,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
"Recording inactivity safeguard started with prompt thresholds {PromptThresholds}, auto-stop {AutoStopAfter}, check interval {CheckInterval}",
|
||||
string.Join(", ", promptThresholds),
|
||||
safeguardOptions.AutoStopAfter,
|
||||
safeguardOptions.CheckInterval);
|
||||
checkInterval);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -869,9 +991,38 @@ public sealed class MeetingRecordingCoordinator
|
||||
snapshot.LastTranscriptActivityAt);
|
||||
}
|
||||
|
||||
var pauseStartedAt = snapshot.TranscriptionPausedAt;
|
||||
if (pauseStartedAt is not null)
|
||||
{
|
||||
var maximumPauseDuration = safeguardOptions.MaximumPauseDuration;
|
||||
if (maximumPauseDuration > TimeSpan.Zero &&
|
||||
inactivityClock.Now - pauseStartedAt.Value >= maximumPauseDuration)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Recording inactivity safeguard stopping meeting after transcription remained paused for {PauseDuration}",
|
||||
maximumPauseDuration);
|
||||
var stopStatus = await StopAsync(
|
||||
null,
|
||||
run,
|
||||
null,
|
||||
pauseStartedAt,
|
||||
CancellationToken.None);
|
||||
if (!stopStatus.IsRecording)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await inactivityClock.DelayAsync(
|
||||
checkInterval,
|
||||
run.CaptureCancellation);
|
||||
continue;
|
||||
}
|
||||
|
||||
var now = inactivityClock.Now;
|
||||
var inactivityDuration = now - snapshot.LastTranscriptActivityAt;
|
||||
if (safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
|
||||
if (safeguardOptions.Enabled &&
|
||||
safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
|
||||
inactivityDuration >= safeguardOptions.AutoStopAfter)
|
||||
{
|
||||
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
|
||||
@@ -879,8 +1030,16 @@ public sealed class MeetingRecordingCoordinator
|
||||
"Recording inactivity safeguard auto-stopping meeting after {InactivityDuration} without transcript text; inferred end time {InferredEndTime}",
|
||||
inactivityDuration,
|
||||
inferredEndTime);
|
||||
await StopAsync(inferredEndTime, CancellationToken.None);
|
||||
return;
|
||||
var stopStatus = await StopAsync(
|
||||
inferredEndTime,
|
||||
run,
|
||||
snapshot.ActivityVersion,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
if (!stopStatus.IsRecording)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var threshold in promptThresholds)
|
||||
@@ -897,6 +1056,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
inactivityDuration,
|
||||
threshold,
|
||||
inferredEndTime);
|
||||
var promptActivityVersion = snapshot.ActivityVersion;
|
||||
await inactivityPromptService.ShowStopPromptAsync(
|
||||
new MeetingInactivityPromptRequest(
|
||||
inactivityDuration,
|
||||
@@ -912,16 +1072,36 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
if (response == MeetingInactivityPromptResponse.Stop)
|
||||
{
|
||||
await StopAsync(inferredEndTime, CancellationToken.None);
|
||||
await StopAsync(
|
||||
inferredEndTime,
|
||||
run,
|
||||
promptActivityVersion,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
else if (response == MeetingInactivityPromptResponse.Pause)
|
||||
{
|
||||
await SetTranscriptionPausedAsync(
|
||||
true,
|
||||
run,
|
||||
promptActivityVersion,
|
||||
CancellationToken.None);
|
||||
}
|
||||
},
|
||||
run.CaptureCancellation);
|
||||
|
||||
var currentActivity = run.GetTranscriptActivitySnapshot();
|
||||
if (currentActivity.IsTranscriptionPaused ||
|
||||
currentActivity.ActivityVersion != promptActivityVersion)
|
||||
{
|
||||
await DismissInactivityPromptsAsync(run.CaptureCancellation);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await inactivityClock.DelayAsync(
|
||||
GetInactivitySafeguardCheckInterval(safeguardOptions),
|
||||
checkInterval,
|
||||
run.CaptureCancellation);
|
||||
}
|
||||
}
|
||||
@@ -1821,6 +2001,11 @@ public sealed class MeetingRecordingCoordinator
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldRunInactivitySafeguard(RecordingInactivitySafeguardOptions options)
|
||||
{
|
||||
return options.Enabled || options.MaximumPauseDuration > TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private static TimeSpan GetInactivitySafeguardCheckInterval(
|
||||
RecordingInactivitySafeguardOptions options)
|
||||
{
|
||||
@@ -1877,6 +2062,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
private TimeSpan? lastTranscriptSegmentEnd;
|
||||
private long transcriptActivityVersion;
|
||||
private DateTimeOffset? inferredEndTime;
|
||||
private bool isTranscriptionPaused;
|
||||
private DateTimeOffset? transcriptionPausedAt;
|
||||
|
||||
public RecordingRun(
|
||||
CancellationTokenSource captureCancellation,
|
||||
@@ -1960,6 +2147,17 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public bool HasAttachedPromptMetadata { get; private set; }
|
||||
|
||||
public bool IsTranscriptionPaused
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (transcriptActivityGate)
|
||||
{
|
||||
return isTranscriptionPaused;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||
|
||||
public DateTimeOffset? InferredEndTime
|
||||
@@ -1983,6 +2181,37 @@ public sealed class MeetingRecordingCoordinator
|
||||
HasAttachedPromptMetadata = true;
|
||||
}
|
||||
|
||||
public bool TrySetTranscriptionPaused(
|
||||
bool isPaused,
|
||||
DateTimeOffset changedAt,
|
||||
long? expectedActivityVersion)
|
||||
{
|
||||
lock (transcriptActivityGate)
|
||||
{
|
||||
if (expectedActivityVersion is not null &&
|
||||
transcriptActivityVersion != expectedActivityVersion.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTranscriptionPaused == isPaused)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
isTranscriptionPaused = isPaused;
|
||||
transcriptionPausedAt = isPaused ? changedAt : null;
|
||||
|
||||
if (!isPaused)
|
||||
{
|
||||
lastTranscriptActivityAt = changedAt;
|
||||
}
|
||||
|
||||
transcriptActivityVersion++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
IsAborted = true;
|
||||
@@ -2011,13 +2240,17 @@ public sealed class MeetingRecordingCoordinator
|
||||
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async ValueTask WriteAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
public async ValueTask RouteCapturedAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
await PipelineGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
AppendAudio(chunk);
|
||||
await Pipeline.WriteAsync(chunk, cancellationToken);
|
||||
var routedChunk = IsTranscriptionPaused
|
||||
? chunk with { Pcm = new byte[chunk.Pcm.Length] }
|
||||
: chunk;
|
||||
await RecordedAudio.AppendAsync(routedChunk, cancellationToken);
|
||||
AppendAudio(routedChunk);
|
||||
await Pipeline.WriteAsync(routedChunk, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -2201,7 +2434,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
return new TranscriptActivitySnapshot(
|
||||
lastTranscriptActivityAt,
|
||||
lastTranscriptSegmentEnd,
|
||||
transcriptActivityVersion);
|
||||
transcriptActivityVersion,
|
||||
isTranscriptionPaused,
|
||||
transcriptionPausedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2221,6 +2456,35 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryClaimInactivityStop(long expectedActivityVersion, DateTimeOffset endTime)
|
||||
{
|
||||
lock (transcriptActivityGate)
|
||||
{
|
||||
if (isTranscriptionPaused || transcriptActivityVersion != expectedActivityVersion)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
inferredEndTime = endTime;
|
||||
StopCapture();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryClaimMaximumPauseStop(DateTimeOffset expectedPauseStartedAt)
|
||||
{
|
||||
lock (transcriptActivityGate)
|
||||
{
|
||||
if (!isTranscriptionPaused || transcriptionPausedAt != expectedPauseStartedAt)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StopCapture();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendAudio(AudioChunk chunk)
|
||||
{
|
||||
speakerSampleCollector.AppendAudio(chunk);
|
||||
@@ -2350,7 +2614,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
public sealed record TranscriptActivitySnapshot(
|
||||
DateTimeOffset LastTranscriptActivityAt,
|
||||
TimeSpan? LastTranscriptSegmentEnd,
|
||||
long ActivityVersion);
|
||||
long ActivityVersion,
|
||||
bool IsTranscriptionPaused,
|
||||
DateTimeOffset? TranscriptionPausedAt);
|
||||
|
||||
private static int StateRank(AssistantContextState state)
|
||||
{
|
||||
@@ -2405,7 +2671,8 @@ public sealed record RecordingStatus(
|
||||
string? AssistantContextPath,
|
||||
string? SummaryPath,
|
||||
RecordingProcessState State = RecordingProcessState.Idle,
|
||||
string? LaunchProfile = null);
|
||||
string? LaunchProfile = null,
|
||||
bool IsPaused = false);
|
||||
|
||||
public enum RecordingProcessState
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
private const string NotificationGroup = "meeting-inactivity";
|
||||
private readonly ConcurrentDictionary<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
|
||||
private readonly object registrationGate = new();
|
||||
private readonly object notificationGate = new();
|
||||
private bool registered;
|
||||
|
||||
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
|
||||
@@ -37,24 +37,27 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
|
||||
try
|
||||
{
|
||||
EnsureRegistered();
|
||||
var promptId = Guid.NewGuid().ToString("N");
|
||||
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
||||
logger.LogInformation(
|
||||
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
||||
promptId,
|
||||
request.Threshold);
|
||||
var notification = BuildNotification(promptId, request);
|
||||
notification.Show(toast =>
|
||||
lock (notificationGate)
|
||||
{
|
||||
toast.Group = NotificationGroup;
|
||||
toast.Tag = promptId;
|
||||
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
||||
});
|
||||
logger.LogInformation(
|
||||
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
||||
promptId,
|
||||
request.Threshold);
|
||||
EnsureRegistered();
|
||||
var promptId = Guid.NewGuid().ToString("N");
|
||||
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
||||
logger.LogInformation(
|
||||
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
||||
promptId,
|
||||
request.Threshold);
|
||||
var notification = BuildNotification(promptId, request);
|
||||
notification.Show(toast =>
|
||||
{
|
||||
toast.Group = NotificationGroup;
|
||||
toast.Tag = promptId;
|
||||
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
||||
});
|
||||
logger.LogInformation(
|
||||
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
||||
promptId,
|
||||
request.Threshold);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -76,6 +79,36 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
lock (notificationGate)
|
||||
{
|
||||
if (pendingPrompts.IsEmpty)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
pendingPrompts.Clear();
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.History.RemoveGroup(NotificationGroup);
|
||||
logger.LogInformation("Dismissed all native Windows inactivity notifications");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to dismiss native Windows inactivity notifications");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
@@ -83,29 +116,28 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!registered)
|
||||
lock (notificationGate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!registered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
||||
registered = false;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRegistered()
|
||||
{
|
||||
if (registered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (registrationGate)
|
||||
lock (notificationGate)
|
||||
{
|
||||
if (registered)
|
||||
{
|
||||
@@ -122,29 +154,25 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
string promptId,
|
||||
MeetingInactivityPromptRequest request)
|
||||
{
|
||||
var yesButton = new ToastButton()
|
||||
.SetContent("Yes")
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", "stop")
|
||||
.SetBackgroundActivation();
|
||||
|
||||
var noButton = new ToastButton()
|
||||
.SetContent("No")
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", "continue")
|
||||
.SetBackgroundActivation();
|
||||
|
||||
return new ToastContentBuilder()
|
||||
var builder = new ToastContentBuilder()
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText("Stop meeting?")
|
||||
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
|
||||
.AddButton(yesButton)
|
||||
.AddButton(noButton);
|
||||
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.");
|
||||
|
||||
foreach (var action in MeetingInactivityPromptActions.All)
|
||||
{
|
||||
builder.AddButton(new ToastButton()
|
||||
.SetContent(action.Content)
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", action.ResponseArgument)
|
||||
.SetBackgroundActivation());
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
||||
@@ -177,10 +205,9 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
return;
|
||||
}
|
||||
|
||||
var response = TryGetArgument(arguments, "response", out var responseValue) &&
|
||||
string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase)
|
||||
? MeetingInactivityPromptResponse.Stop
|
||||
: MeetingInactivityPromptResponse.Continue;
|
||||
var response = TryGetArgument(arguments, "response", out var responseValue)
|
||||
? MeetingInactivityPromptActions.ParseResponse(responseValue)
|
||||
: MeetingInactivityPromptResponse.Continue;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
|
||||
@@ -9,6 +9,8 @@ public enum MeetingTaskbarAction
|
||||
OpenSubmenu,
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
PauseTranscription,
|
||||
UnpauseTranscription,
|
||||
AbortRecording,
|
||||
SwitchProfile,
|
||||
SelectMicrophone,
|
||||
@@ -51,6 +53,15 @@ public static class MeetingTaskbarMenuBuilder
|
||||
}
|
||||
|
||||
var secondaryControls = new List<MeetingTaskbarMenuItem>();
|
||||
if (status.IsRecording)
|
||||
{
|
||||
secondaryControls.Add(new MeetingTaskbarMenuItem(
|
||||
status.IsPaused ? "Unpause transcription" : "Pause transcription",
|
||||
status.IsPaused
|
||||
? MeetingTaskbarAction.UnpauseTranscription
|
||||
: MeetingTaskbarAction.PauseTranscription));
|
||||
}
|
||||
|
||||
if (microphones is { Count: > 0 })
|
||||
{
|
||||
secondaryControls.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
||||
|
||||
@@ -249,6 +249,12 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
case MeetingTaskbarAction.StopRecording:
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.PauseTranscription:
|
||||
await coordinator.SetTranscriptionPausedAsync(true, CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.UnpauseTranscription:
|
||||
await coordinator.SetTranscriptionPausedAsync(false, CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.AbortRecording:
|
||||
await coordinator.AbortAsync(CancellationToken.None);
|
||||
break;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"00:10:00"
|
||||
],
|
||||
"AutoStopAfter": "00:30:00",
|
||||
"MaximumPauseDuration": "04:00:00",
|
||||
"InferredEndPadding": "00:01:00",
|
||||
"CheckInterval": "00:00:15"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user