Update Broadcast button: diff before push, offline=local only, live=API; Go Live empty-field prompt
This commit is contained in:
@@ -7,7 +7,7 @@ namespace ytLive.Models;
|
|||||||
/// the fields that stay mutable for the whole life of the broadcast. Fields
|
/// the fields that stay mutable for the whole life of the broadcast. Fields
|
||||||
/// that only exist pre-launch (scheduledStartTime, contentDetails) are NOT
|
/// that only exist pre-launch (scheduledStartTime, contentDetails) are NOT
|
||||||
/// here by design; see ai.md "Broadcast metadata side pull-out".</summary>
|
/// 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 Title { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
private readonly Func<LayoutStore> _store;
|
private readonly Func<LayoutStore> _store;
|
||||||
private readonly Func<YouTubeStreamService?> _streamService;
|
private readonly Func<YouTubeStreamService?> _streamService;
|
||||||
private readonly Func<string?> _broadcastId;
|
private readonly Func<string?> _broadcastId;
|
||||||
|
private readonly Func<bool> _isLive;
|
||||||
|
|
||||||
private string _title = string.Empty;
|
private string _title = string.Empty;
|
||||||
private string _description = string.Empty;
|
private string _description = string.Empty;
|
||||||
@@ -31,15 +32,18 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
private bool _isDrawerOpen;
|
private bool _isDrawerOpen;
|
||||||
private bool _isUpdating;
|
private bool _isUpdating;
|
||||||
private string? _updateError;
|
private string? _updateError;
|
||||||
|
private BroadcastMetadata? _lastSyncedSnapshot;
|
||||||
|
|
||||||
public LiveBroadcastFormViewModel(
|
public LiveBroadcastFormViewModel(
|
||||||
Func<LayoutStore> store,
|
Func<LayoutStore> store,
|
||||||
Func<YouTubeStreamService?> streamService,
|
Func<YouTubeStreamService?> streamService,
|
||||||
Func<string?> broadcastId)
|
Func<string?> broadcastId,
|
||||||
|
Func<bool>? isLive = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_streamService = streamService;
|
_streamService = streamService;
|
||||||
_broadcastId = broadcastId;
|
_broadcastId = broadcastId;
|
||||||
|
_isLive = isLive ?? (() => false);
|
||||||
UpdateBroadcastCommand = new RelayCommand(_ => _ = UpdateRemoteAsync(), _ => CanUpdateRemote);
|
UpdateBroadcastCommand = new RelayCommand(_ => _ = UpdateRemoteAsync(), _ => CanUpdateRemote);
|
||||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||||
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
|
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
|
||||||
@@ -92,9 +96,10 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _updateError, value);
|
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()
|
public void Load()
|
||||||
{
|
{
|
||||||
var meta = _store().LoadBroadcastMetadata();
|
var meta = _store().LoadBroadcastMetadata();
|
||||||
@@ -110,6 +115,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(Visibility));
|
OnPropertyChanged(nameof(Visibility));
|
||||||
OnPropertyChanged(nameof(MadeForKids));
|
OnPropertyChanged(nameof(MadeForKids));
|
||||||
OnPropertyChanged(nameof(ScheduledStartText));
|
OnPropertyChanged(nameof(ScheduledStartText));
|
||||||
|
SnapshotSynced();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Called at Go Live with what was actually inserted remotely.</summary>
|
/// <summary>Called at Go Live with what was actually inserted remotely.</summary>
|
||||||
@@ -119,6 +125,7 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
Description = description;
|
Description = description;
|
||||||
ScheduledStartTime = scheduledStart;
|
ScheduledStartTime = scheduledStart;
|
||||||
UpdateError = null;
|
UpdateError = null;
|
||||||
|
SnapshotSynced();
|
||||||
}
|
}
|
||||||
|
|
||||||
public BroadcastMetadata ToMetadata() => new()
|
public BroadcastMetadata ToMetadata() => new()
|
||||||
@@ -147,13 +154,36 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
var id = _broadcastId();
|
var id = _broadcastId();
|
||||||
var service = _streamService();
|
var service = _streamService();
|
||||||
if (id == null || service == null || IsUpdating) return;
|
if (IsUpdating) return;
|
||||||
IsUpdating = true;
|
IsUpdating = true;
|
||||||
UpdateError = null;
|
UpdateError = null;
|
||||||
try
|
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;
|
UpdateError = error;
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
SnapshotSynced();
|
||||||
|
IsDrawerOpen = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -165,4 +195,6 @@ public sealed class LiveBroadcastFormViewModel : ViewModelBase
|
|||||||
IsUpdating = false;
|
IsUpdating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SnapshotSynced() => _lastSyncedSnapshot = ToMetadata();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1565,7 +1565,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
BroadcastForm = new Services.LiveBroadcastFormViewModel(
|
BroadcastForm = new Services.LiveBroadcastFormViewModel(
|
||||||
() => _layoutStore,
|
() => _layoutStore,
|
||||||
() => _youtubeStream,
|
() => _youtubeStream,
|
||||||
() => CurrentBroadcastId);
|
() => CurrentBroadcastId,
|
||||||
|
() => IsLive);
|
||||||
|
|
||||||
// Restore the last picked mic so the mixer's first capture attaches to
|
// Restore the last picked mic so the mixer's first capture attaches to
|
||||||
// the same, already-vetted device (green dot) or reports it missing
|
// the same, already-vetted device (green dot) or reports it missing
|
||||||
@@ -2961,6 +2962,13 @@ public class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||||||
OnPropertyChanged(nameof(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
|
// 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,
|
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
|
||||||
// the stream continues without on-panel chat.
|
// the stream continues without on-panel chat.
|
||||||
|
|||||||
@@ -142,6 +142,82 @@ public sealed class BroadcastPullOutTests
|
|||||||
Assert.True(root.GetProperty("status").GetProperty("selfDeclaredMadeForKids").GetBoolean());
|
Assert.True(root.GetProperty("status").GetProperty("selfDeclaredMadeForKids").GetBoolean());
|
||||||
Assert.Equal(new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc).ToString("o"),
|
Assert.Equal(new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc).ToString("o"),
|
||||||
root.GetProperty("snippet").GetProperty("scheduledStartTime").GetString());
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user