Onboarding step + Open /ui button + recording duration in footer
Some checks failed
CI / build-and-test (push) Failing after 30s

Three small UX wins:

1. Onboarding gained step 5 ('Run Teams headless') and step 6 ('Drive from another machine') so new operators discover the auto-launch/auto-hide + LAN-reachable workflows. Existing 'where things live' step renumbered to 7.

2. Settings → DISPLAY → Control surface URL row gains an Open button next to Copy that fires the URL into the default browser via Process.Start with UseShellExecute. Operators previewing how the embedded /ui control panel looks on a phone/tablet no longer need to copy-paste manually.

3. Recording badge in footer now shows 'REC 3 · 12:45' instead of just 'REC 3'. RecordingElapsed VM property maintains a separate timer from the session timer because recording can start AFTER the meeting begins; operators tracking 'how long has the archive copy been rolling' need that distinct duration.
This commit is contained in:
Zac Gaetano 2026-05-10 21:05:30 -04:00
parent 3f71a4f29a
commit acc569dd24
4 changed files with 132 additions and 3 deletions

View file

@ -370,6 +370,8 @@
VerticalAlignment="Center">
<Run Text="REC "/>
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding RecordingElapsed, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
@ -1367,14 +1369,24 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Settings.ControlSurfaceUrl}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Open"
Command="{Binding Settings.OpenControlSurfaceCommand}"
Padding="10,2"
Margin="0,0,4,0"
FontSize="11"
ToolTip="Open this URL in your default browser. Useful for previewing how the control panel looks before pointing a phone or second-monitor at it."/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Copy"
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"

View file

@ -184,8 +184,8 @@
</StackPanel>
</Border>
<!-- Step 5 — Where things live -->
<Border Style="{StaticResource Wd.Card}" Padding="16">
<!-- Step 5 — Headless Teams ("I only see TeamsISO") -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
@ -200,6 +200,62 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Run Teams headless (optional)"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="To use TeamsISO as your only window: tick both 'Launch Microsoft Teams on TeamsISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
</StackPanel>
</Border>
<!-- Step 6 — Where things live -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="6"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Drive from another machine (optional)"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. TeamsISO listens on http://&lt;your-lan-ip&gt;:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
</StackPanel>
</Border>
<!-- Step 7 — Where things live -->
<Border Style="{StaticResource Wd.Card}" Padding="16">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="7"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="If something breaks…"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>

View file

@ -95,8 +95,35 @@ public sealed class GlobalSettingsViewModel : ObservableObject
// Clipboard occasionally errors when something else has it locked.
}
});
OpenControlSurfaceCommand = new RelayCommand(() =>
{
// Hands the URL off to the OS shell so the user's default browser
// opens it. Operators previewing how the control panel looks on
// their phone / tablet / second monitor would otherwise have to
// copy-paste the URL — this is a one-click preview.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = ControlSurfaceUrl,
UseShellExecute = true,
});
}
catch (Exception ex)
{
_toast?.Warn($"Couldn't open: {ex.Message}");
}
});
}
/// <summary>
/// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
/// default browser. Enabled regardless of whether the control surface is
/// running — if it isn't, the browser will show a connection error, which
/// is informative; operators learn the surface needs to be enabled first.
/// </summary>
public RelayCommand OpenControlSurfaceCommand { get; }
private void ResetOutputDefaults()
{
var confirm = MessageBox.Show(

View file

@ -206,6 +206,21 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
private int _activeRecordingCount;
/// <summary>
/// Elapsed time since recording started this session, formatted as
/// "MM:SS" (or "HH:MM:SS" past an hour). Empty when nothing is
/// recording. Resets when all recordings stop, restarts on the next
/// rec-on transition. Useful for operators tracking "how long has the
/// show been rolling".
/// </summary>
public string RecordingElapsed
{
get => _recordingElapsed;
private set => SetField(ref _recordingElapsed, value);
}
private string _recordingElapsed = string.Empty;
private DateTimeOffset? _recordingStartedAt;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning
{
@ -585,6 +600,25 @@ public sealed class MainViewModel : ObservableObject, IDisposable
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
IsRecording = ActiveRecordingCount > 0;
// Recording session timer — independent of the global session timer
// since recording can start AFTER the meeting begins (or vice versa)
// and operators want to know exactly how long the archive copy has
// been rolling. Resets to null when all recordings stop, so the
// next rec-on transition starts the timer from 00:00.
if (IsRecording)
{
_recordingStartedAt ??= DateTimeOffset.UtcNow;
var recElapsed = DateTimeOffset.UtcNow - _recordingStartedAt.Value;
RecordingElapsed = recElapsed.TotalHours >= 1
? $"{(int)recElapsed.TotalHours:D2}:{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}"
: $"{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}";
}
else
{
_recordingStartedAt = null;
RecordingElapsed = string.Empty;
}
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model: