From 89ab83be2e9f109172896d8bece73d1540f8aadf Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 25 Aug 2026 17:25:25 -0700 Subject: [PATCH] Update Broadcast button: diff before push, offline=local only, live=API; Go Live empty-field prompt --- Models/BroadcastMetadata.cs | 2 +- Services/LiveBroadcastFormViewModel.cs | 42 ++++++++++++-- ViewModels/MainViewModel.cs | 10 +++- ytLive.Tests/BroadcastPullOutTests.cs | 76 ++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/Models/BroadcastMetadata.cs b/Models/BroadcastMetadata.cs index 93973aa..30ed24f 100644 --- a/Models/BroadcastMetadata.cs +++ b/Models/BroadcastMetadata.cs @@ -7,7 +7,7 @@ namespace ytLive.Models; /// the fields that stay mutable for the whole life of the broadcast. Fields /// that only exist pre-launch (scheduledStartTime, contentDetails) are NOT /// here by design; see ai.md "Broadcast metadata side pull-out". -public sealed class BroadcastMetadata +public sealed record BroadcastMetadata { public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/Services/LiveBroadcastFormViewModel.cs b/Services/LiveBroadcastFormViewModel.cs index b1913b3..9b07f36 100644 --- a/Services/LiveBroadcastFormViewModel.cs +++ b/Services/LiveBroadcastFormViewModel.cs @@ -21,6 +21,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase private readonly Func _store; private readonly Func _streamService; private readonly Func _broadcastId; + private readonly Func _isLive; private string _title = string.Empty; private string _description = string.Empty; @@ -31,15 +32,18 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase private bool _isDrawerOpen; private bool _isUpdating; private string? _updateError; + private BroadcastMetadata? _lastSyncedSnapshot; public LiveBroadcastFormViewModel( Func store, Func streamService, - Func broadcastId) + Func broadcastId, + Func? isLive = null) { _store = store; _streamService = streamService; _broadcastId = broadcastId; + _isLive = isLive ?? (() => false); UpdateBroadcastCommand = new RelayCommand(_ => _ = UpdateRemoteAsync(), _ => CanUpdateRemote); ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen); CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false); @@ -92,9 +96,10 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase private set => SetProperty(ref _updateError, value); } - public bool CanUpdateRemote => !string.IsNullOrEmpty(_broadcastId()) && !IsUpdating; + public bool CanUpdateRemote => !IsUpdating; - /// Pull everything from the store (startup / Go Live refresh). + /// Pull everything from the store (startup / Go Live refresh). + /// Seeds the synced snapshot so the next Update click knows what is already remote. public void Load() { var meta = _store().LoadBroadcastMetadata(); @@ -110,6 +115,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase OnPropertyChanged(nameof(Visibility)); OnPropertyChanged(nameof(MadeForKids)); OnPropertyChanged(nameof(ScheduledStartText)); + SnapshotSynced(); } /// Called at Go Live with what was actually inserted remotely. @@ -119,6 +125,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase Description = description; ScheduledStartTime = scheduledStart; UpdateError = null; + SnapshotSynced(); } public BroadcastMetadata ToMetadata() => new() @@ -147,13 +154,36 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase { var id = _broadcastId(); var service = _streamService(); - if (id == null || service == null || IsUpdating) return; + if (IsUpdating) return; IsUpdating = true; UpdateError = null; try { - var error = await service.UpdateBroadcast(id, ToMetadata()); + var current = ToMetadata(); + if (!_isLive()) + { + UpdateError = null; + IsDrawerOpen = false; + return; + } + if (id == null || service == null) + { + UpdateError = null; + IsDrawerOpen = false; + return; + } + if (_lastSyncedSnapshot != null && current.Equals(_lastSyncedSnapshot)) + { + UpdateError = "No changes to update"; + return; + } + var error = await service.UpdateBroadcast(id, current); UpdateError = error; + if (error == null) + { + SnapshotSynced(); + IsDrawerOpen = false; + } } catch (Exception ex) { @@ -165,4 +195,6 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase IsUpdating = false; } } + + private void SnapshotSynced() => _lastSyncedSnapshot = ToMetadata(); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 8d21586..0755f29 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -1565,7 +1565,8 @@ public class MainViewModel : ViewModelBase BroadcastForm = new Services.LiveBroadcastFormViewModel( () => _layoutStore, () => _youtubeStream, - () => CurrentBroadcastId); + () => CurrentBroadcastId, + () => IsLive); // Restore the last picked mic so the mixer's first capture attaches to // the same, already-vetted device (green dot) or reports it missing @@ -2961,6 +2962,13 @@ public class MainViewModel : ViewModelBase AppLog.Write($"Broadcast created: {_currentBroadcastId}"); OnPropertyChanged(nameof(CurrentBroadcastId)); + if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription)) + { + BroadcastForm.IsDrawerOpen = true; + _notifications.Warning("Complete your broadcast details", + "Your title and description are empty — fill them in from the Text panel on the right."); + } + // Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's // contentDetails, then start the poll loop. Chat is non-fatal — if it fails, // the stream continues without on-panel chat. diff --git a/ytLive.Tests/BroadcastPullOutTests.cs b/ytLive.Tests/BroadcastPullOutTests.cs index f08f73c..6c952a7 100644 --- a/ytLive.Tests/BroadcastPullOutTests.cs +++ b/ytLive.Tests/BroadcastPullOutTests.cs @@ -142,6 +142,82 @@ public sealed class BroadcastPullOutTests Assert.True(root.GetProperty("status").GetProperty("selfDeclaredMadeForKids").GetBoolean()); Assert.Equal(new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc).ToString("o"), root.GetProperty("snippet").GetProperty("scheduledStartTime").GetString()); + + // --- Diff + close drawer tests --- + + var auth2 = new YouTubeAuthService("test-client", "test-secret"); + auth2.SetSession(new YouTubeChannel + { + AccessToken = "token2", + TokenExpiry = DateTime.UtcNow.AddHours(1), + }); + var stub2 = new StubHandler(); + var service2 = new YouTubeStreamService(auth2, new HttpClient(stub2)); + + string? broadcastId = "bc456"; + + // --- Offline path (isLive = false): saves locally, never pushes API --- + var formOffline = new LiveBroadcastFormViewModel( + () => new LayoutStore(tempDb), + () => service2, + () => broadcastId, + () => false); + + formOffline.Load(); + Assert.False(formOffline.IsDrawerOpen); + + // No edits → stays open, no API call + formOffline.IsDrawerOpen = true; + formOffline.UpdateBroadcastCommand.Execute(null); + Assert.Null(stub2.Request); + Assert.False(formOffline.IsDrawerOpen); + + // Edit Title → closes drawer, still no API call (offline = local save only) + stub2.Request = null; + formOffline.Title = "Changed offline"; + formOffline.IsDrawerOpen = true; + formOffline.UpdateBroadcastCommand.Execute(null); + Assert.Null(stub2.Request); + Assert.False(formOffline.IsDrawerOpen); + + // Re-load → the offline edit persisted to the store + formOffline.Load(); + Assert.Equal("Changed offline", formOffline.Title); + + // --- Live path (isLive = true): pushes to API --- + var formLive = new LiveBroadcastFormViewModel( + () => new LayoutStore(tempDb), + () => service2, + () => broadcastId, + () => true); + + formLive.Load(); + formLive.IsDrawerOpen = true; + + // No edits → "No changes to update", stays open + stub2.Request = null; + formLive.UpdateBroadcastCommand.Execute(null); + Assert.Null(stub2.Request); + Assert.Equal("No changes to update", formLive.UpdateError); + Assert.True(formLive.IsDrawerOpen); + + // Edit Title → HTTP PUT fires, drawer closes + stub2.Request = null; + formLive.Title = "Live update"; + formLive.UpdateBroadcastCommand.Execute(null); + Assert.NotNull(stub2.Request); + Assert.Equal(HttpMethod.Put, stub2.Request!.Method); + Assert.Null(formLive.UpdateError); + Assert.False(formLive.IsDrawerOpen); + + // Re-seed snapshot via Load, then edit again → second upload fires + stub2.Request = null; + formLive.Load(); + formLive.Description = "Live desc"; + formLive.UpdateBroadcastCommand.Execute(null); + Assert.NotNull(stub2.Request); + Assert.Null(formLive.UpdateError); + Assert.False(formLive.IsDrawerOpen); } finally {