Broadcast Text pull-out (Live screen) + launch geometry 1920x1040/1366x768 with restore
This commit is contained in:
+20
-17
@@ -2,33 +2,36 @@
|
||||
|
||||
## Branch
|
||||
**`main`** @ HEAD — committed and pushed, working tree clean. **No feature branches pre-1.0
|
||||
(decided 2026-08-24): all work lands on `main` per work unit; every `taskNN-*` branch deleted**
|
||||
(TASK 24/25/26+27 fast-forwarded into main; stale `social-bar`/`webcam-validation` removed too).
|
||||
(decided 2026-08-24): all work lands on `main` per work unit; every `taskNN-*` branch deleted.**
|
||||
|
||||
## Shipped (this session, now all on main)
|
||||
- TASK 26 — webcam (+) menu gates on the app-wide identity (`CanAddWebcam` = staged != null && `_webcam == null`; XAML binding + tooltip; test `WebcamMenuGateTests`).
|
||||
- TASK 27 — YouTube Chat layer one-per-layout gate (`CanAddYouTubeChat`, AddSource refuses a second) + legacy label heal ("Chat" → "YouTube Chat" on LoadLayout; creator renames untouched; test `ChatLayerGateTests`). Creator's ask: "do the same thing with the YouTube chat layer" + make the Layers row match the (+) picklist.
|
||||
- TASK 19/23 status flip — code survey confirmed Control Surface UX fully shipped (thumbnail strip, two-state left panel, right panel gone, transitions, edit gating); map corrected.
|
||||
- **TASK 20 step 1 — global hotkeys** (`Services/GlobalHotkeys.cs`, `MainViewModel.HandleHotkey`, `MainWindow.OnSourceInitialized` wiring): F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop mute, F9 TRAX. `IHotkeyRegistrar` seam + `RegistrarOverride`. Test `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose`. Hotkeys validated by creator.
|
||||
- **ON-AIR sign** (creator ask): new status light + "ON-AIR" right of REC in the top bar (`OnAirBrush`: gray offline / red public / green private); old left-of-REC hardcoded indicator removed. Labels share one `StatusSignText` style in Controls.xaml (creator dinged inconsistency when ON-AIR text took the status color — labels are neutral, dots carry state). Test `OnAirSignTests.OnAirBrush_Follows_Offline_Public_And_Private`.
|
||||
- Suite: 227 total, 226 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs shipped same-commit.
|
||||
- Visual pass on TASK 26/27 validated by creator (2026-08-24).
|
||||
## Shipped (this session, all on main)
|
||||
- TASK 26 — webcam (+) menu gates on the app-wide identity (test `WebcamMenuGateTests`).
|
||||
- TASK 27 — YouTube Chat one-per-layout gate + legacy label heal (test `ChatLayerGateTests`).
|
||||
- TASK 19/23 status flip — Control Surface UX verified shipped; map corrected.
|
||||
- TASK 20 step 1 — global hotkeys F1-F9 (`Services/GlobalHotkeys.cs`, test `GlobalHotkeyTests`). Validated by creator.
|
||||
- ON-AIR sign right of REC — gray offline / red public / green private (`OnAirBrush`), shared `StatusSignText` style so REC/ON-AIR can't drift (creator dinged inconsistency when labels took different colors). Test `OnAirSignTests`.
|
||||
- **Broadcast metadata pull-out + launch geometry** — white "Text" tab on preview's right edge (Live screen only) slides out the broadcast form: Title, Description, Tags csv, Visibility, Made-for-Kids, read-only Scheduled Start. Edits persist to Settings `Broadcast.*` immediately; Update Broadcast PUTs `liveBroadcasts?part=snippet,status` via new `YouTubeStreamService.UpdateBroadcast`. Go Live prefills from the form + captures back what was inserted. Old Default Stream Title/Description removed from App Settings (properties deleted). Window: default 1920×1040, MinWidth 1366, MinHeight 768, Manual startup, size+position persisted on close (`RestoreBounds`) and restored clamped to minimums + primary work area. Test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`.
|
||||
- Suite: 228 total, 227 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs same-commit throughout.
|
||||
|
||||
## ⚠️ Landmines
|
||||
|
||||
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification.
|
||||
- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by these branches.
|
||||
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Trust nothing from those sessions without manual verification.
|
||||
- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — unrelated to this session.
|
||||
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once.
|
||||
- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both). Cost a debug cycle in GlobalHotkeyTests.
|
||||
- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both).
|
||||
- **Two MainWindows alive at once double-register hotkeys** — second window's RegisterHotKey fails silently-by-log. Tests close windows before opening the next.
|
||||
- **Closing a MainWindow OVERWRITES saved window geometry** (Closing → SaveWindowGeometry). In tests that assert restore-from-DB, save state AFTER closing prior windows.
|
||||
- **liveBroadcasts.update replaces the whole snippet part** — `UpdateBroadcast` echoes scheduledStartTime or YouTube clears the schedule.
|
||||
- **contentDetails lock in created/ready** — latency/DVR/embed etc. cannot be edited while live; deliberately absent from the pull-out form.
|
||||
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after.
|
||||
- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing.
|
||||
- Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
|
||||
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted.
|
||||
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item.
|
||||
|
||||
## Next steps
|
||||
1. Creator visual pass: ON-AIR sign states (gray offline → red on public live → green on private live).
|
||||
2. Continue TASK 20: config UI + schema persistence for bindings (then conflict detection).
|
||||
3. Queue after TASK 20: TASK 21 media source, TASK 22 audio sync offset, TASK 17 web source, TASK 18 local recording.
|
||||
1. Creator visual pass: Text tab appears only while streaming; drawer slides over preview; fields pre-load across restarts; Update button greys until Go Live succeeds (needs real OAuth round-trip to verify remotely).
|
||||
2. First-launch check: window opens 1920×1040 centered; resize/move/close/reopen restores geometry; can't shrink below 1366×768.
|
||||
3. Continue queue: TASK 20 step 2 (hotkey config UI + persistence), TASK 17 web source, TASK 18 local recording, TASK 21 media source.
|
||||
4. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL.
|
||||
|
||||
## Build
|
||||
|
||||
+122
-16
@@ -8,11 +8,11 @@
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:notifications="clr-namespace:Notification.Wpf.Controls;assembly=Notification.Wpf"
|
||||
mc:Ignorable="d"
|
||||
Title="{Binding WindowTitle}" Height="720" Width="1280"
|
||||
MinWidth="1180" MinHeight="600"
|
||||
Title="{Binding WindowTitle}" Height="1040" Width="1920"
|
||||
MinWidth="1366" MinHeight="768"
|
||||
Icon="/ytLive;component/Assets/llamacasty-icon.png"
|
||||
Background="#1a1a2e"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
WindowStartupLocation="Manual"
|
||||
Closing="MainWindow_Closing"
|
||||
Loaded="MainWindow_Loaded"
|
||||
Activated="MainWindow_Activated"
|
||||
@@ -1114,6 +1114,120 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ BROADCAST TEXT PULL-OUT (Live screen only) ═══
|
||||
White "Text" tab on the preview's right edge; clicking it
|
||||
slides out the broadcast-metadata form. Edits persist
|
||||
locally immediately; Update pushes liveBroadcasts.update. -->
|
||||
<Grid Grid.Row="0"
|
||||
Visibility="{Binding IsBroadcastMetadataVisible, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||
<Border Background="#16213e" ClipToBounds="True">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BroadcastForm.IsDrawerOpen}" Value="True">
|
||||
<DataTrigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="320" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.EnterActions>
|
||||
<DataTrigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="0" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.ExitActions>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<!-- Fixed-width inner so text never reflows mid-animation -->
|
||||
<Grid Width="320" Margin="14,10,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="BROADCAST TEXT" Style="{StaticResource SectionHeader}"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding BroadcastForm.CloseDrawerCommand}"
|
||||
ToolTip="Close">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="TITLE" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.Title, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="90" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.Description, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="TAGS (COMMA-SEPARATED)" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.TagsCsv, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="VISIBILITY" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,10"
|
||||
ItemsSource="{Binding BroadcastForm.VisibilityOptions}"
|
||||
SelectedItem="{Binding BroadcastForm.Visibility}"/>
|
||||
<CheckBox Content="Made for kids" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,10"
|
||||
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
|
||||
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
|
||||
Foreground="#888899" FontSize="12" Margin="0,0,0,4"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="0,10,0,0">
|
||||
<Button Style="{StaticResource YtButton}" Content="Update Broadcast"
|
||||
Command="{Binding BroadcastForm.UpdateBroadcastCommand}"/>
|
||||
<TextBlock Text="{Binding BroadcastForm.UpdateError}"
|
||||
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
Visibility="{Binding BroadcastForm.UpdateError, Converter={StaticResource NotNullToVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The white tab -->
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand"
|
||||
ToolTip="Broadcast text fields">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick"
|
||||
Command="{Binding BroadcastForm.OpenDrawerCommand}"/>
|
||||
</Border.InputBindings>
|
||||
<TextBlock Text="Text" Foreground="#e94560" FontWeight="Bold" FontSize="15"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock.LayoutTransform>
|
||||
<RotateTransform Angle="90"/>
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ TRAX METER (below preview, never overlaps) ═══ -->
|
||||
<Border Grid.Row="2" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="#D9000000" CornerRadius="6" Padding="12,8"
|
||||
@@ -1387,21 +1501,13 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- App Settings -->
|
||||
<!-- App Settings: broadcast fields live in the preview's
|
||||
Text pull-out now — this panel keeps app-level prefs only.
|
||||
(Currently empty; the overlay shell stays for future use.) -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Default Stream Title" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding DefaultStreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Default Stream Description" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="70" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Margin="0,0,0,12"
|
||||
Text="{Binding DefaultStreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="These prefill the Start Stream dialog. Streams always start Private — change visibility on YouTube after going live."
|
||||
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="Broadcast text fields moved to the Text tab on the Live screen (right edge of the preview)."
|
||||
Style="{StaticResource YtLabel}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Report Bug -->
|
||||
|
||||
@@ -29,6 +29,7 @@ public partial class MainWindow : Window
|
||||
UpdateTaskbarOverlay();
|
||||
UpdateSelectionOverlay();
|
||||
LoadAvatarImage();
|
||||
RestoreWindowGeometry();
|
||||
AppLog.Write("MainWindow ctor: end");
|
||||
}
|
||||
|
||||
@@ -37,6 +38,52 @@ public partial class MainWindow : Window
|
||||
AppLog.Write("MainWindow loaded");
|
||||
}
|
||||
|
||||
// ─── Window geometry persistence (launch size/position) ───
|
||||
// Saved on close (RestoreBounds so a maximized window remembers its
|
||||
// un-maximize rect); restored in the ctor before the window is shown.
|
||||
// Values clamp to the minimums and to the primary work area — a position
|
||||
// saved on a since-disconnected secondary monitor falls back on-screen.
|
||||
|
||||
private void RestoreWindowGeometry()
|
||||
{
|
||||
try
|
||||
{
|
||||
var saved = _viewModel.RestoreWindowState();
|
||||
if (saved == null) return;
|
||||
var (left, top, width, height, state) = saved.Value;
|
||||
|
||||
var work = SystemParameters.WorkArea;
|
||||
width = Math.Clamp(width, MinWidth, work.Width);
|
||||
height = Math.Clamp(height, MinHeight, work.Height);
|
||||
left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
|
||||
top = Math.Clamp(top, work.Top, work.Bottom - 80);
|
||||
|
||||
Left = left;
|
||||
Top = top;
|
||||
Width = width;
|
||||
Height = height;
|
||||
if (state == "Maximized") WindowState = WindowState.Maximized;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Window geometry restore failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveWindowGeometry()
|
||||
{
|
||||
try
|
||||
{
|
||||
var bounds = RestoreBounds;
|
||||
var state = WindowState == WindowState.Maximized ? "Maximized" : "Normal";
|
||||
_viewModel.PersistWindowState(bounds.Left, bounds.Top, bounds.Width, bounds.Height, state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Window geometry save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private ytLive.Services.GlobalHotkeyManager? _hotkeys;
|
||||
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
@@ -66,6 +113,7 @@ public partial class MainWindow : Window
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
SaveWindowGeometry();
|
||||
_viewModel.Shutdown();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>The creator-editable broadcast metadata (TASK: broadcast pull-out).
|
||||
/// These map onto the YouTube liveBroadcast resource's snippet/status parts —
|
||||
/// 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 string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
/// <summary>"private" / "unlisted" / "public".</summary>
|
||||
public string Visibility { get; set; } = "private";
|
||||
public bool MadeForKids { get; set; }
|
||||
/// <summary>Set at Go Live (insert); read-only in the form.</summary>
|
||||
public DateTime? ScheduledStartTime { get; set; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -130,6 +130,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control
|
||||
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
|
||||
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Live screen only** (`IsBroadcastMetadataVisible => IsLive`). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
@@ -960,6 +961,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
|
||||
3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending).
|
||||
4. ✅ **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. Verified shipped 2026-08-24.
|
||||
5. **TASK 20** — Hotkeys — global keyboard shortcuts. Step 1 (F1-F9 defaults) shipped 2026-08-24; config UI + persistence remain.
|
||||
5b. ✅ **Broadcast metadata pull-out + launch geometry** — shipped 2026-08-24 (row 29 above). Live-screen "Text" tab → broadcast form, persistence + remote update; window default/minimums bumped (1920×1040 / 1366×768) with size+position restore.
|
||||
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
|
||||
7. **TASK 18** — Local recording — independent, but pairs with stream.
|
||||
8. **TASK 21** — Media source — video file playback for non-Live scenes.
|
||||
|
||||
+29
-14
@@ -84,8 +84,6 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _isAboutOpen;
|
||||
private bool _isLicensesOpen;
|
||||
private string _overlayTitle = string.Empty;
|
||||
private string _defaultStreamTitle = string.Empty;
|
||||
private string _defaultStreamDescription = string.Empty;
|
||||
private string _bugReportText = string.Empty;
|
||||
private string _bugReportEmail = string.Empty;
|
||||
private string _featureRequestText = string.Empty;
|
||||
@@ -465,6 +463,7 @@ public class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
OnPropertyChanged(nameof(IsLive));
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(IsBroadcastMetadataVisible));
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
@@ -1242,17 +1241,23 @@ public class MainViewModel : ViewModelBase
|
||||
private set => SetProperty(ref _overlayTitle, value);
|
||||
}
|
||||
|
||||
public string DefaultStreamTitle
|
||||
{
|
||||
get => _defaultStreamTitle;
|
||||
set => SetProperty(ref _defaultStreamTitle, value);
|
||||
}
|
||||
// ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
|
||||
|
||||
public string DefaultStreamDescription
|
||||
{
|
||||
get => _defaultStreamDescription;
|
||||
set => SetProperty(ref _defaultStreamDescription, value);
|
||||
}
|
||||
/// <summary>The broadcast id of the current/last session; null until Go Live
|
||||
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
|
||||
public string? CurrentBroadcastId => _currentBroadcastId;
|
||||
|
||||
/// <summary>The pull-out exists on the Live screen only — visible while streaming.</summary>
|
||||
public bool IsBroadcastMetadataVisible => IsLive;
|
||||
|
||||
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
|
||||
|
||||
// Window geometry pass-through (code-behind owns the WPF specifics).
|
||||
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
||||
=> _layoutStore.LoadWindowState();
|
||||
|
||||
public void PersistWindowState(double left, double top, double width, double height, string state)
|
||||
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
||||
|
||||
// ─── Resolution quality dropdown (bottom bar) ───
|
||||
// Tiers offered to the creator before going live. First = default. The
|
||||
@@ -1561,6 +1566,11 @@ public class MainViewModel : ViewModelBase
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
|
||||
BroadcastForm = new Services.LiveBroadcastFormViewModel(
|
||||
() => _layoutStore,
|
||||
() => _youtubeStream,
|
||||
() => CurrentBroadcastId);
|
||||
|
||||
// Restore the last picked mic so the mixer's first capture attaches to
|
||||
// the same, already-vetted device (green dot) or reports it missing
|
||||
// (yellow) instead of silently falling back to the default.
|
||||
@@ -1849,6 +1859,8 @@ public class MainViewModel : ViewModelBase
|
||||
SelectedTransitionType = parsed;
|
||||
TransitionDurationMs = transDur;
|
||||
|
||||
BroadcastForm.Load();
|
||||
|
||||
ScheduleSave();
|
||||
AppLog.Write("LoadLayout end");
|
||||
}
|
||||
@@ -2899,8 +2911,8 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||||
{
|
||||
StreamTitle = DefaultStreamTitle,
|
||||
StreamDescription = DefaultStreamDescription,
|
||||
StreamTitle = BroadcastForm.Title,
|
||||
StreamDescription = BroadcastForm.Description,
|
||||
};
|
||||
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
||||
if (window.ShowDialog() == true)
|
||||
@@ -2908,6 +2920,7 @@ public class MainViewModel : ViewModelBase
|
||||
StreamTitle = dialog.StreamTitle;
|
||||
StreamDescription = dialog.StreamDescription;
|
||||
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
|
||||
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
|
||||
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
||||
? "LlamaCasty"
|
||||
: $"{dialog.StreamTitle} — LlamaCasty";
|
||||
@@ -2950,6 +2963,7 @@ public class MainViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||||
OnPropertyChanged(nameof(CurrentBroadcastId));
|
||||
|
||||
// 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,
|
||||
@@ -3048,6 +3062,7 @@ public class MainViewModel : ViewModelBase
|
||||
_audioMixer.StopLive();
|
||||
_ = _framePump.StopAsync();
|
||||
_currentBroadcastId = null;
|
||||
OnPropertyChanged(nameof(CurrentBroadcastId));
|
||||
// Graceful end completes the session = signs out (the DPAPI token is
|
||||
// cleared so the next Start Stream requires a fresh sign-in). A crash
|
||||
// never runs this, so the token survives and the creator stays signed in.
|
||||
|
||||
@@ -161,6 +161,7 @@ The AI hallucinated through multiple commits tonight on background/scene propert
|
||||
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
|
||||
- TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
|
||||
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
|
||||
- Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below)
|
||||
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
|
||||
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
|
||||
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`.
|
||||
@@ -192,6 +193,22 @@ The app is a **director's control surface**, not an editor. This replaces the OB
|
||||
- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`.
|
||||
- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event.
|
||||
|
||||
### Broadcast metadata pull-out + launch geometry (2026-08-24)
|
||||
|
||||
**The "Text" tab** (Live screen only): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered, visible only while `IsLive` (`IsBroadcastMetadataVisible`). Click slides out a 320px dark drawer over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it; ✕ closes).
|
||||
|
||||
**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
|
||||
- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
|
||||
- Scheduled Start is read-only ("set at Go Live")
|
||||
|
||||
**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func<LayoutStore>` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end.
|
||||
|
||||
**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
|
||||
|
||||
**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
|
||||
|
||||
Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`.
|
||||
|
||||
### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25
|
||||
|
||||
**The Background is a locked per-screen layer (TASK 25): exactly one per canonical
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK: broadcast metadata pull-out + launch geometry. One integration slice:
|
||||
/// (1) pull-out edits persist immediately to the Settings store and re-load,
|
||||
/// (2) a real MainWindow restores saved size/position, clamping off-screen or
|
||||
/// below-minimum values into the primary work area, (3) UpdateBroadcast PATCHes
|
||||
/// liveBroadcasts?part=snippet,status with the full editable field set.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class BroadcastPullOutTests
|
||||
{
|
||||
public BroadcastPullOutTests(RealAppHost app) => _app = app;
|
||||
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
[Fact]
|
||||
public void Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? Request;
|
||||
public string? Body;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Request = request;
|
||||
Body = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"bc123\"}", System.Text.Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-pullout-{Guid.NewGuid():N}.db");
|
||||
ViewModels.MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = default;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new ViewModels.MainViewModel();
|
||||
|
||||
vm.BroadcastForm.Title = "Hello";
|
||||
vm.BroadcastForm.Description = "World";
|
||||
vm.BroadcastForm.TagsCsv = "a, b ,c";
|
||||
vm.BroadcastForm.Visibility = "public";
|
||||
vm.BroadcastForm.MadeForKids = true;
|
||||
|
||||
using (var reader = new LayoutStore(tempDb))
|
||||
{
|
||||
var meta = reader.LoadBroadcastMetadata();
|
||||
Assert.Equal("Hello", meta.Title);
|
||||
Assert.Equal("World", meta.Description);
|
||||
Assert.Equal("a, b ,c", meta.TagsCsv);
|
||||
Assert.Equal("public", meta.Visibility);
|
||||
Assert.True(meta.MadeForKids);
|
||||
}
|
||||
|
||||
vm.BroadcastForm.Load();
|
||||
Assert.Equal("Hello", vm.BroadcastForm.Title);
|
||||
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.True(vm.IsBroadcastMetadataVisible);
|
||||
vm.StreamStatus = StreamStatus.Offline;
|
||||
Assert.False(vm.IsBroadcastMetadataVisible);
|
||||
|
||||
using (var writer = new LayoutStore(tempDb))
|
||||
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
||||
|
||||
window = new MainWindow();
|
||||
Assert.Equal(1500, window.Width);
|
||||
Assert.Equal(900, window.Height);
|
||||
Assert.Equal(100, window.Left);
|
||||
Assert.Equal(50, window.Top);
|
||||
|
||||
window.Close();
|
||||
window = null;
|
||||
|
||||
using (var writer = new LayoutStore(tempDb))
|
||||
writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
|
||||
|
||||
window = new MainWindow();
|
||||
var work = System.Windows.SystemParameters.WorkArea;
|
||||
Assert.Equal(1366, window.Width);
|
||||
Assert.Equal(768, window.Height);
|
||||
Assert.InRange(window.Left, work.Left - window.Width + 120, work.Right - 120);
|
||||
Assert.InRange(window.Top, work.Top, work.Bottom - 80);
|
||||
|
||||
var auth = new YouTubeAuthService("test-client", "test-secret");
|
||||
auth.SetSession(new YouTubeChannel
|
||||
{
|
||||
AccessToken = "token",
|
||||
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
||||
});
|
||||
var stub = new StubHandler();
|
||||
var service = new YouTubeStreamService(auth, new HttpClient(stub));
|
||||
|
||||
var error = service.UpdateBroadcast("bc123", new BroadcastMetadata
|
||||
{
|
||||
Title = "T",
|
||||
Description = "D",
|
||||
TagsCsv = "x,y",
|
||||
Visibility = "Public",
|
||||
MadeForKids = true,
|
||||
ScheduledStartTime = new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(stub.Request);
|
||||
Assert.Equal(HttpMethod.Put, stub.Request!.Method);
|
||||
Assert.Equal("https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status",
|
||||
stub.Request.RequestUri!.ToString());
|
||||
|
||||
using var doc = JsonDocument.Parse(stub.Body!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("bc123", root.GetProperty("id").GetString());
|
||||
Assert.Equal("T", root.GetProperty("snippet").GetProperty("title").GetString());
|
||||
Assert.Equal(2, root.GetProperty("snippet").GetProperty("tags").GetArrayLength());
|
||||
Assert.Equal("public", root.GetProperty("status").GetProperty("privacyStatus").GetString());
|
||||
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());
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
ViewModels.MainViewModel.LayoutPathOverride = null;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user