diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml index 825c15f..aa16a8a 100644 --- a/src/TeamsISO.App/MainWindow.xaml +++ b/src/TeamsISO.App/MainWindow.xaml @@ -865,6 +865,9 @@ + diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs index 6bd0b2e..fff6b22 100644 --- a/src/TeamsISO.App/ViewModels/MainViewModel.cs +++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs @@ -813,7 +813,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable } else { - vm = new ParticipantViewModel(_controller, p); + vm = new ParticipantViewModel(_controller, p, Toast); _byId[p.Id] = vm; Participants.Add(vm); } diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs index ce70d30..21d0833 100644 --- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs +++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs @@ -15,6 +15,7 @@ namespace TeamsISO.App.ViewModels; public sealed class ParticipantViewModel : ObservableObject { private readonly IIsoController _controller; + private readonly ToastViewModel? _toast; private Participant _participant; private bool _isEnabled; private bool _isProcessing; @@ -43,9 +44,10 @@ public sealed class ParticipantViewModel : ObservableObject /// True when is non-null. Bound to Visibility in XAML. public bool HasThumbnail => _thumbnail is not null; - public ParticipantViewModel(IIsoController controller, Participant participant) + public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null) { _controller = controller; + _toast = toast; _participant = participant; _customName = string.Empty; ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing); @@ -75,6 +77,62 @@ public sealed class ParticipantViewModel : ObservableObject RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync, () => _isEnabled && !_isProcessing); + + SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled); + } + + /// + /// Encode the most recent + /// as a PNG and write it to %USERPROFILE%\Pictures\TeamsISO\. Used + /// by the participants' context menu for grabbing a stillframe — useful + /// for highlight reels, social posts, bug reports. Best-effort: a no-op + /// + warn-toast if no frame is currently available (pipeline just spun + /// up, or recording isn't enabled). Filename includes participant name + /// + timestamp so back-to-back snapshots don't collide. + /// + private void SaveSnapshot() + { + try + { + var frame = _controller.GetLatestProcessedFrame(Id); + if (frame is null || frame.Pixels.IsEmpty) + { + _toast?.Warn("No frame available yet — try again in a few seconds"); + return; + } + + var dir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), + "TeamsISO"); + System.IO.Directory.CreateDirectory(dir); + + var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars())); + if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant"; + var path = System.IO.Path.Combine(dir, + $"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png"); + + // ProcessedFrame is BGRA32, top-down. WriteableBitmap with + // Bgra32 pixel format takes the bytes verbatim. + var stride = frame.Width * 4; + var bmp = new System.Windows.Media.Imaging.WriteableBitmap( + frame.Width, frame.Height, + 96, 96, + System.Windows.Media.PixelFormats.Bgra32, null); + bmp.WritePixels( + new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height), + frame.Pixels.ToArray(), stride, 0); + + using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create); + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp)); + encoder.Save(fs); + + _toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}"); + } + catch (Exception ex) + { + _toast?.Warn($"Snapshot failed: {ex.Message}"); + } } /// @@ -358,6 +416,7 @@ public sealed class ParticipantViewModel : ObservableObject /// Open a non-modal floating preview window for this participant. Multi-monitor friendly. public RelayCommand OpenPreviewCommand { get; } + public RelayCommand SaveSnapshotCommand { get; } /// /// Restart the pipeline for this participant: disable, brief pause, re-enable.