Update Broadcast button: diff before push, offline=local only, live=API; Go Live empty-field prompt

This commit is contained in:
2026-08-25 17:25:25 -07:00
parent add024a68d
commit 89ab83be2e
4 changed files with 123 additions and 7 deletions
+1 -1
View File
@@ -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".</summary>
public sealed class BroadcastMetadata
public sealed record BroadcastMetadata
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
+37 -5
View File
@@ -21,6 +21,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
private readonly Func<LayoutStore> _store;
private readonly Func<YouTubeStreamService?> _streamService;
private readonly Func<string?> _broadcastId;
private readonly Func<bool> _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<LayoutStore> store,
Func<YouTubeStreamService?> streamService,
Func<string?> broadcastId)
Func<string?> broadcastId,
Func<bool>? 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;
/// <summary>Pull everything from the store (startup / Go Live refresh).</summary>
/// <summary>Pull everything from the store (startup / Go Live refresh).
/// Seeds the synced snapshot so the next Update click knows what is already remote.</summary>
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();
}
/// <summary>Called at Go Live with what was actually inserted remotely.</summary>
@@ -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();
}
+9 -1
View File
@@ -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.
+76
View File
@@ -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
{