feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README
This commit is contained in:
parent
5958b66bfd
commit
7c7520e2be
3 changed files with 345 additions and 6 deletions
85
README.md
85
README.md
|
|
@ -1,19 +1,92 @@
|
||||||
# TeamsISO
|
# TeamsISO
|
||||||
|
|
||||||
Per-Participant NDI ISO Controller for Microsoft Teams.
|
**Per-Participant NDI ISO Controller for Microsoft Teams.**
|
||||||
|
|
||||||
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate and resolution per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into a switcher (vMix, OBS, Ross, hardware capture).
|
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a
|
||||||
|
live-production environment. It receives each participant's NDI stream,
|
||||||
|
normalizes framerate / resolution / aspect / audio per a configured target,
|
||||||
|
and re-emits clean, individually-addressable NDI sources for ingestion into
|
||||||
|
a switcher (vMix, OBS, Ross, hardware capture).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Discovers participants** as Teams broadcasts each one over NDI, surfacing
|
||||||
|
the operator-friendly display name (handles current "MS Teams - Name"
|
||||||
|
format and the legacy "(Teams) Name" format).
|
||||||
|
- **Normalizes feeds** to a consistent framerate, resolution, aspect mode,
|
||||||
|
and audio routing — so the downstream switcher gets predictable inputs
|
||||||
|
regardless of what each participant's webcam is doing.
|
||||||
|
- **Routes per-participant** as separate NDI sources with a configurable
|
||||||
|
output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens).
|
||||||
|
- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json
|
||||||
|
+ ffmpeg convert.cmd — so post-production gets a clean per-guest archive.
|
||||||
|
- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide
|
||||||
|
Teams' UI windows during a show, drive in-call controls (mute, camera,
|
||||||
|
share, leave, raise hand) via UIAutomation.
|
||||||
|
- **Operator presets** save the current per-participant ISO assignment and
|
||||||
|
custom output names, applicable on next launch automatically.
|
||||||
|
- **Live preview thumbnails** per participant in the participants table,
|
||||||
|
plus pop-out floating preview windows (right-click → Open preview…) for
|
||||||
|
multi-monitor monitoring.
|
||||||
|
- **External control surface** — REST + WebSocket on `127.0.0.1:9755` and
|
||||||
|
OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck /
|
||||||
|
TouchOSC integration. Self-contained HTML control panel at
|
||||||
|
[`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller.
|
||||||
|
- **Crash diagnostics** wired to a rolling daily Serilog file sink under
|
||||||
|
`%LOCALAPPDATA%\TeamsISO\Logs\`.
|
||||||
|
- **Update check** against `forge.wilddragon.net`'s release API — manual or
|
||||||
|
silent on launch (throttled to 24h).
|
||||||
|
- **Diagnostic bundle export** zips logs + config + presets for bug reports.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans.
|
Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on
|
||||||
|
code-signing the MSI and a smoke pass against a real Teams meeting.
|
||||||
|
See `CHANGELOG.md` for the [Unreleased] entry.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Requires .NET 8 SDK.
|
Requires .NET 8 SDK on Windows (the `TeamsISO.App` host is `net8.0-windows`
|
||||||
|
WPF).
|
||||||
|
|
||||||
dotnet build
|
dotnet restore TeamsISO.Windows.slnf
|
||||||
dotnet test
|
dotnet build TeamsISO.Windows.slnf -c Release
|
||||||
|
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
|
||||||
|
|
||||||
|
The shipped helper scripts in the repo root automate this:
|
||||||
|
|
||||||
|
pwsh -File .\build-and-test.ps1
|
||||||
|
pwsh -File .\commit-and-push.ps1
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Control surface API](docs/CONTROL-SURFACE.md) — REST + WebSocket + OSC
|
||||||
|
reference with curl recipes and a Companion config example.
|
||||||
|
- [Releasing](docs/RELEASING.md) — tag-push workflow, MSI signing path.
|
||||||
|
- [Architecture spec](docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md)
|
||||||
|
— design overview.
|
||||||
|
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
|
||||||
|
— Phase E roadmap.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `F1` | Open help / cheat sheet |
|
||||||
|
| `Ctrl + M` | Drop a timestamped marker into every active recording |
|
||||||
|
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
|
||||||
|
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
|
||||||
|
|
||||||
|
## File locations
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\Logs\` | Rolling daily diagnostic logs |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files |
|
||||||
|
| `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output |
|
||||||
|
| `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<Window x:Class="TeamsISO.App.NotesWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Show notes"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="540" Height="560"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="6"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="24,16,24,20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="SHOW NOTES"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
x:Name="DateLine"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,12,0,12"/>
|
||||||
|
|
||||||
|
<!-- Notes view -->
|
||||||
|
<Border Grid.Row="2" Style="{StaticResource Wd.Card}" Padding="0">
|
||||||
|
<ScrollViewer x:Name="Scroller"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Padding="14,12">
|
||||||
|
<TextBox x:Name="NotesText"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Inline note input — quick stamping without leaving the dialog -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
x:Name="NewNoteBox"
|
||||||
|
Padding="10,7"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
KeyDown="OnNewNoteKey"
|
||||||
|
ToolTip="Type a note and press Enter (or click 'Add'). Lands in today's file with a HH:mm:ss timestamp."/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Add"
|
||||||
|
Click="OnAddNote"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Grid.Row="4" Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Open in editor"
|
||||||
|
Click="OnOpenInEditor"
|
||||||
|
Padding="14,8"
|
||||||
|
ToolTip="Launch the notes file in the system default editor."/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Refresh"
|
||||||
|
Click="OnRefresh"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="14,8"/>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Close"
|
||||||
|
Click="OnClose"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline viewer for the daily show-notes file. Reads
|
||||||
|
/// <see cref="NotesService.TodayPath"/> on open and polls every 2s while
|
||||||
|
/// shown so REST/OSC-driven note appends surface live without the operator
|
||||||
|
/// having to click Refresh.
|
||||||
|
///
|
||||||
|
/// We don't allow editing here — the file is intentionally a one-way log
|
||||||
|
/// (operator stamps, post-show review). If someone wants to edit, they
|
||||||
|
/// click "Open in editor" and use Notepad.
|
||||||
|
/// </summary>
|
||||||
|
public partial class NotesWindow : Window
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _refreshTimer;
|
||||||
|
private long _lastFileSize = -1;
|
||||||
|
private DateTime _lastFileWrite = DateTime.MinValue;
|
||||||
|
|
||||||
|
public NotesWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_refreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(2),
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
|
||||||
|
ReloadFromDisk();
|
||||||
|
_refreshTimer.Start();
|
||||||
|
};
|
||||||
|
Closed += (_, _) => _refreshTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap mtime/size check — only re-reads the file when something changed.
|
||||||
|
/// Saves the textbox a flicker on every 2s tick when no notes are being
|
||||||
|
/// added. Falls through to a full reload if the file got smaller (operator
|
||||||
|
/// might have edited externally).
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshIfChanged()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = NotesService.TodayPath;
|
||||||
|
if (!File.Exists(path)) return;
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
if (info.Length != _lastFileSize || info.LastWriteTimeUtc != _lastFileWrite)
|
||||||
|
ReloadFromDisk();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk hiccups shouldn't stop the timer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadFromDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = NotesService.TodayPath;
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
NotesText.Text = "No notes yet. Stamp one via the REST or OSC endpoint and refresh.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
_lastFileSize = info.Length;
|
||||||
|
_lastFileWrite = info.LastWriteTimeUtc;
|
||||||
|
NotesText.Text = File.ReadAllText(path);
|
||||||
|
// Scroll to bottom so the latest stamp is visible — operators are
|
||||||
|
// typically reading "what just happened" not "what happened first."
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
Scroller.ScrollToEnd();
|
||||||
|
}), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
NotesText.Text = "Couldn't read notes file: " + ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append the input box's text to today's notes file via NotesService,
|
||||||
|
/// then clear the box and refresh the view. Bound to the "Add" button +
|
||||||
|
/// Enter key in the input. Empty/whitespace input is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private void OnAddNote(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var text = NewNoteBox.Text?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
if (NotesService.Append(text))
|
||||||
|
{
|
||||||
|
NewNoteBox.Clear();
|
||||||
|
ReloadFromDisk();
|
||||||
|
NewNoteBox.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enter key in the input commits the note, same as the Add button.</summary>
|
||||||
|
private void OnNewNoteKey(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == System.Windows.Input.Key.Enter)
|
||||||
|
{
|
||||||
|
OnAddNote(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenInEditor(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = NotesService.TodayPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; if no .md handler is registered the OS shows its own dialog.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue