Broadcast Text pull-out (Live screen) + launch geometry 1920x1040/1366x768 with restore
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user