Broadcast Text pull-out (Live screen) + launch geometry 1920x1040/1366x768 with restore

This commit is contained in:
2026-08-24 11:04:41 -07:00
parent d5b611d8d5
commit b988e6d72e
11 changed files with 670 additions and 48 deletions
+49
View File
@@ -1194,6 +1194,55 @@ public class LayoutStore : IDisposable
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
}
// ─── Broadcast metadata (broadcast pull-out) + window geometry ───
public Models.BroadcastMetadata LoadBroadcastMetadata()
{
return new Models.BroadcastMetadata
{
Title = GetSetting("Broadcast.Title") ?? string.Empty,
Description = GetSetting("Broadcast.Description") ?? string.Empty,
TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
Visibility = GetSetting("Broadcast.Visibility") ?? "private",
MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
};
}
public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
{
UpsertSetting("Broadcast.Title", meta.Title);
UpsertSetting("Broadcast.Description", meta.Description);
UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
UpsertSetting("Broadcast.Visibility", meta.Visibility);
UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
UpsertSetting("Broadcast.ScheduledStartTime",
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
}
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
{
var left = GetSetting("Window.Left");
var top = GetSetting("Window.Top");
var width = GetSetting("Window.Width");
var height = GetSetting("Window.Height");
var state = GetSetting("Window.State");
if (left == null || top == null || width == null || height == null) return null;
if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
!double.TryParse(width, out var w) || !double.TryParse(height, out var h))
return null;
return (l, t, w, h, state ?? "Normal");
}
public void SaveWindowState(double left, double top, double width, double height, string state)
{
UpsertSetting("Window.Left", left.ToString("F0"));
UpsertSetting("Window.Top", top.ToString("F0"));
UpsertSetting("Window.Width", width.ToString("F0"));
UpsertSetting("Window.Height", height.ToString("F0"));
UpsertSetting("Window.State", state);
}
private string? GetSetting(string key)
{
using var cmd = _connection.CreateCommand();
+168
View File
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Backing VM for the broadcast metadata pull-out (the "Text" tab on the Live
/// screen). Owns the creator-editable liveBroadcast fields: every edit is
/// persisted to the Settings store immediately (pre-load on next run), and the
/// Update command pushes the whole snippet/status set to YouTube via
/// liveBroadcasts.update. The drawer open/close state lives here too so the
/// XAML binds one object.
/// </summary>
public sealed class LiveBroadcastFormViewModel : ViewModelBase
{
private readonly Func<LayoutStore> _store;
private readonly Func<YouTubeStreamService?> _streamService;
private readonly Func<string?> _broadcastId;
private string _title = string.Empty;
private string _description = string.Empty;
private string _tagsCsv = string.Empty;
private string _visibility = "private";
private bool _madeForKids;
private DateTime? _scheduledStartTime;
private bool _isDrawerOpen;
private bool _isUpdating;
private string? _updateError;
public LiveBroadcastFormViewModel(
Func<LayoutStore> store,
Func<YouTubeStreamService?> streamService,
Func<string?> broadcastId)
{
_store = store;
_streamService = streamService;
_broadcastId = broadcastId;
UpdateBroadcastCommand = new RelayCommand(_ => _ = UpdateRemoteAsync(), _ => CanUpdateRemote);
OpenDrawerCommand = new RelayCommand(_ => IsDrawerOpen = true);
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
}
public ICommand UpdateBroadcastCommand { get; }
public RelayCommand OpenDrawerCommand { get; }
public RelayCommand CloseDrawerCommand { get; }
public string Title { get => _title; set { if (SetProperty(ref _title, value)) Persist(); } }
public string Description { get => _description; set { if (SetProperty(ref _description, value)) Persist(); } }
public string TagsCsv { get => _tagsCsv; set { if (SetProperty(ref _tagsCsv, value)) Persist(); } }
public bool MadeForKids { get => _madeForKids; set { if (SetProperty(ref _madeForKids, value)) Persist(); } }
public string Visibility
{
get => _visibility;
set { if (SetProperty(ref _visibility, value)) Persist(); }
}
public IReadOnlyList<string> VisibilityOptions { get; } = new[] { "private", "unlisted", "public" };
public DateTime? ScheduledStartTime
{
get => _scheduledStartTime;
private set => SetProperty(ref _scheduledStartTime, value);
}
public string ScheduledStartText => ScheduledStartTime?.ToLocalTime().ToString("g") ?? "set at Go Live";
public bool IsDrawerOpen
{
get => _isDrawerOpen;
set => SetProperty(ref _isDrawerOpen, value);
}
public bool IsUpdating
{
get => _isUpdating;
private set
{
if (SetProperty(ref _isUpdating, value))
OnPropertyChanged(nameof(CanUpdateRemote));
}
}
public string? UpdateError
{
get => _updateError;
private set => SetProperty(ref _updateError, value);
}
public bool CanUpdateRemote => !string.IsNullOrEmpty(_broadcastId()) && !IsUpdating;
/// <summary>Pull everything from the store (startup / Go Live refresh).</summary>
public void Load()
{
var meta = _store().LoadBroadcastMetadata();
_title = meta.Title;
_description = meta.Description;
_tagsCsv = meta.TagsCsv;
_visibility = meta.Visibility;
_madeForKids = meta.MadeForKids;
ScheduledStartTime = meta.ScheduledStartTime;
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Description));
OnPropertyChanged(nameof(TagsCsv));
OnPropertyChanged(nameof(Visibility));
OnPropertyChanged(nameof(MadeForKids));
OnPropertyChanged(nameof(ScheduledStartText));
}
/// <summary>Called at Go Live with what was actually inserted remotely.</summary>
public void CaptureGoLive(string title, string description, DateTime scheduledStart)
{
Title = title;
Description = description;
ScheduledStartTime = scheduledStart;
UpdateError = null;
}
public BroadcastMetadata ToMetadata() => new()
{
Title = Title,
Description = Description,
TagsCsv = TagsCsv,
Visibility = Visibility,
MadeForKids = MadeForKids,
ScheduledStartTime = ScheduledStartTime,
};
private void Persist()
{
try
{
_store().SaveBroadcastMetadata(ToMetadata());
}
catch (Exception ex)
{
AppLog.Write($"Broadcast metadata save failed: {ex.Message}");
}
}
private async Task UpdateRemoteAsync()
{
var id = _broadcastId();
var service = _streamService();
if (id == null || service == null || IsUpdating) return;
IsUpdating = true;
UpdateError = null;
try
{
var error = await service.UpdateBroadcast(id, ToMetadata());
UpdateError = error;
}
catch (Exception ex)
{
AppLog.Write($"Broadcast update failed: {ex.Message}");
UpdateError = ex.Message;
}
finally
{
IsUpdating = false;
}
}
}
+46 -1
View File
@@ -2,9 +2,9 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using ytLive.Models;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
/// <summary>
/// Manages YouTube live stream lifecycle — create broadcasts,
/// bind stream keys, monitor health.
@@ -79,6 +79,51 @@ public class YouTubeStreamService
return details;
}
/// <summary>
/// Pushes the creator-editable broadcast fields to YouTube via
/// liveBroadcasts.update (part=snippet,status). Valid any time, including
/// while live. Returns null on success, otherwise a human-readable error.
/// Note: liveBroadcasts.update REPLACES the snippet part, so scheduledStartTime
/// is re-sent unchanged from the stored value — omitting it would clear the
/// schedule server-side.
/// </summary>
public async Task<string?> UpdateBroadcast(string broadcastId, BroadcastMetadata meta)
{
if (!await EnsureToken()) return "not signed in";
var tags = meta.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var broadcast = new
{
id = broadcastId,
snippet = new
{
title = meta.Title,
description = meta.Description,
tags = tags,
// update replaces the whole snippet part; echo the original schedule
scheduledStartTime = (meta.ScheduledStartTime ?? DateTime.UtcNow).ToString("o"),
categoryId = "22"
},
status = new
{
privacyStatus = string.IsNullOrWhiteSpace(meta.Visibility) ? "private" : meta.Visibility.ToLowerInvariant(),
selfDeclaredMadeForKids = meta.MadeForKids
}
};
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.PutAsJsonAsync(
$"{ApiBase}/liveBroadcasts?part=snippet,status", broadcast);
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
return $"YouTube rejected the update ({(int)response.StatusCode})";
}
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
/// lists existing streams first and reuses the one with cdn.isReusable=true,
/// creating it with variable resolution/frame rate on first use. Binding to a