Onboarding step + Open /ui button + recording duration in footer
Some checks failed
CI / build-and-test (push) Failing after 30s
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:
parent
3f71a4f29a
commit
acc569dd24
4 changed files with 132 additions and 3 deletions
|
|
@ -370,6 +370,8 @@
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Run Text="REC "/>
|
<Run Text="REC "/>
|
||||||
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
|
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding RecordingElapsed, Mode=OneWay}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
@ -1367,14 +1369,24 @@
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="{Binding Settings.ControlSurfaceUrl}"
|
Text="{Binding Settings.ControlSurfaceUrl}"
|
||||||
Style="{StaticResource Wd.Text.Mono}"
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<Button Grid.Column="1"
|
<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}"
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
Content="Copy"
|
Content="Copy"
|
||||||
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"
|
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Step 5 — Where things live -->
|
<!-- Step 5 — Headless Teams ("I only see TeamsISO") -->
|
||||||
<Border Style="{StaticResource Wd.Card}" Padding="16">
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
<Border Width="22" Height="22"
|
<Border Width="22" Height="22"
|
||||||
|
|
@ -200,6 +200,62 @@
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</Border>
|
</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://<your-lan-ip>: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…"
|
<TextBlock Text="If something breaks…"
|
||||||
Style="{StaticResource Wd.Text.Heading}"
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,35 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
// Clipboard occasionally errors when something else has it locked.
|
// 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()
|
private void ResetOutputDefaults()
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Show(
|
var confirm = MessageBox.Show(
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,21 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
private int _activeRecordingCount;
|
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>
|
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
|
||||||
public bool IsControlSurfaceRunning
|
public bool IsControlSurfaceRunning
|
||||||
{
|
{
|
||||||
|
|
@ -585,6 +600,25 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
|
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
|
||||||
IsRecording = ActiveRecordingCount > 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
|
// Session timer — start on first ISO going live, reset when none are
|
||||||
// live anymore. Subsequent enables after a full-zero gap restart the
|
// live anymore. Subsequent enables after a full-zero gap restart the
|
||||||
// timer rather than resuming, which is the operator's mental model:
|
// timer rather than resuming, which is the operator's mental model:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue