feat(ui): color and acknowledge pinned log errors

Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
This commit is contained in:
2026-11-02 18:17:18 -08:00
parent 01a3fcaaf7
commit a6aea5bf26
23 changed files with 365 additions and 100 deletions
+3 -2
View File
@@ -19,10 +19,11 @@ public partial class App : Application
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
AppLog.Write(args.ExceptionObject is Exception ex
? "AppDomain unhandled exception: " + ex
: "AppDomain unhandled exception object: " + args.ExceptionObject);
: "AppDomain unhandled exception object: " + args.ExceptionObject,
AppLogMessageType.Error, AppLogErrorClass.Class1);
DispatcherUnhandledException += (_, args) =>
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin");
// Attribution (2026-09-04): every take must be traceable to the exact binary
+50 -14
View File
@@ -989,20 +989,56 @@
</ListBox>
</Grid>
<ListBox x:Name="LogListBox" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding CurrentLaunchLogEntries}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding IsLogsTabSelected, Converter={StaticResource BoolToVis}}"
ScrollViewer.CanContentScroll="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Text}" Style="{StaticResource PropertyHint}"
TextWrapping="Wrap" Margin="6,4"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Visibility="{Binding IsLogsTabSelected, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0" MaxHeight="160" VerticalScrollBarVisibility="Auto"
Visibility="{Binding HasPinnedLogEntries, Converter={StaticResource BoolToVis}}">
<StackPanel>
<TextBlock Text="Needs attention" Style="{StaticResource PropertyHeader}" Margin="0,0,0,4"/>
<ItemsControl x:Name="PinnedLogPanel" ItemsSource="{Binding PinnedLogEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="6,4" Margin="0,0,0,4" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}">
<DockPanel LastChildFill="True">
<Button DockPanel.Dock="Right" Content="Acknowledge"
Style="{StaticResource PropertyButton}" Margin="8,0,0,0"
VerticalAlignment="Top"
Command="{Binding DataContext.AcknowledgeLogEntryCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>
<StackPanel>
<TextBlock Text="{Binding ErrorClassLabel}"
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"/>
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"/>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<ListBox x:Name="LogListBox" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding CurrentLaunchLogEntries}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
ScrollViewer.CanContentScroll="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="6,4">
<TextBlock DockPanel.Dock="Left" Text="{Binding TypeLabel}"
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"
Margin="0,0,8,0"/>
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Grid>
</Grid>
+3 -2
View File
@@ -20,8 +20,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
its separate current-event ticker controls. Outside edit mode, the left panel
offers Live Chat first and Logs second; Logs shows this process's newest AppLog
entries first and excludes earlier file contents.
offers Live Chat first and Logs second; Logs wraps and colors typed entries,
pins explicit Class 1/2 errors above the timeline until acknowledged, then
returns them to the timeline; it excludes earlier file contents.
## Window ↔ control contract
+13 -13
View File
@@ -1,24 +1,24 @@
# HANDOFF — 2026-11-02 (Logs tab + test sweep)
# HANDOFF — 2026-11-02 (Logs severity and acknowledgment)
## State
- `main`; all work is committed locally, not pushed.
- Non-edit left panel has Live Chat and Logs tabs. Logs shows the newest 500 AppLog entries from
this process only; file format and rotation are unchanged. Feature commit: `9fa06c6`.
- The 13 failures from the prior full-suite run were resolved in separate focused commits; see
`git log` for each test-failure task.
- `./scripts/verify.sh` passes: clean app build, 0 warnings, 522/522 tests, scope check.
- `main`; previous Logs tab and 13 test-failure fixes are committed locally, not pushed.
- Logs wraps and colors by explicit message type. Class 1 (fatal/unhandled) and Class 2 (recoverable,
actionable) are independent metadata; Class 1 pins above Class 2. Acknowledging unpins but retains
the entry in the current-launch timeline. Disk format and rotation are unchanged.
- Logs integration test passes; clean app build is 0 warnings and scope-check passes. The last full
run was 521/522: only the real-mouse layer-reorder test failed while the creator was using the
desktop. It was not rerun; the focused drag test passes in isolation.
## Next action
Day 1's separate system-message strip was replaced by the Logs tab per the creator's ruling. Next
release check: Debug-only `InternalsVisibleTo`. Keep one integration test per change; do not push
absent a milestone/checkpoint request.
Commit this Logs enhancement with the interactive-mouse test exception recorded. Do not rerun that
test while the creator is playing; do not push absent a checkpoint request.
## Landmines
- Build/test with Windows `dotnet.exe`; full suite can hang in WASAPI teardown.
- Any integration test constructing `MainWindow` must use a temporary `LayoutPathOverride` and
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
mouse input. It passed in isolation; do not run it while the desktop is in use.
- Any integration test constructing `MainWindow` must set a temporary `LayoutPathOverride` and
clear it in `finally`.
- Debug-only `InternalsVisibleTo` remains an outstanding release check.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
+53 -3
View File
@@ -2,6 +2,24 @@ using System.IO;
namespace ytLive.Helpers;
/// <summary>Presentation category; independent from the entry's attention class.</summary>
public enum AppLogMessageType
{
Information,
Success,
Warning,
Error,
}
/// <summary>Explicit attention priority, assigned by the writer rather than inferred from text or source.</summary>
public enum AppLogErrorClass
{
/// <summary>Fatal or unhandled failure; highest priority.</summary>
Class1 = 1,
/// <summary>Recoverable failure that still requires attention.</summary>
Class2 = 2,
}
/// <summary>
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
@@ -39,6 +57,15 @@ public static class AppLog
}
public static void Write(string message)
=> Write(message, AppLogMessageType.Information);
public static void WriteWarning(string message)
=> Write(message, AppLogMessageType.Warning);
public static void WriteSuccess(string message)
=> Write(message, AppLogMessageType.Success);
public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
{
try
{
@@ -46,7 +73,9 @@ public static class AppLog
{
var entry = new AppLogEntry(
++_nextSequence,
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
type,
errorClass);
CurrentLaunchEntries.Add(entry);
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
CurrentLaunchEntries.RemoveAt(0);
@@ -65,8 +94,29 @@ public static class AppLog
public static void Write(Exception exception, string message)
{
Write($"{message}: {exception}");
Write($"{message}: {exception}", AppLogMessageType.Error);
}
public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
=> Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
}
public sealed record AppLogEntry(long Sequence, string Text);
public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
AppLogErrorClass? ErrorClass)
{
public string? ErrorClassLabel => ErrorClass switch
{
AppLogErrorClass.Class1 => "CLASS 1",
AppLogErrorClass.Class2 => "CLASS 2",
_ => null,
};
public string TypeLabel => ErrorClassLabel ?? Type switch
{
AppLogMessageType.Information => "INFO",
AppLogMessageType.Success => "SUCCESS",
AppLogMessageType.Warning => "WARNING",
AppLogMessageType.Error => "ERROR",
_ => Type.ToString().ToUpperInvariant(),
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|------|---------|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab; it never loads prior-launch file contents |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
+2 -2
View File
@@ -123,7 +123,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
// animations (or the frame it already holds) exactly as a missing file.
// Logged so a silent blank box traces back to a decode failure.
failed = true;
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})", AppLogErrorClass.Class2);
}
if (!ct.IsCancellationRequested)
@@ -224,4 +224,4 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
}
return chunks;
}
}
}
+2 -2
View File
@@ -370,7 +370,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert sound byte failed");
AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
StopSoundByte();
}
}
@@ -413,7 +413,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert clip setup failed");
AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ public sealed class AlertAudioOutput : IDisposable
{
// No output device (a headless stream box, a locked-down session): the alert still
// renders, it is simply silent. Never let audio take the alert down.
AppLog.Write(ex, "Alert audio output failed");
AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
Stop();
}
}
+1 -1
View File
@@ -422,7 +422,7 @@ public sealed class AudioMixer : IDisposable
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
{
_lastLoopErrorLogged = now;
AppLog.Write(ex, "Audio live loop error");
AppLog.Write(ex, "Audio live loop error", AppLogErrorClass.Class2);
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
}
+6 -3
View File
@@ -317,7 +317,8 @@ public sealed class CameraManager : IDisposable
if (suspects.Count > 0)
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CameraFailed?.Invoke(session.DeviceId, enriched);
_ = SafeStopAsync(session.Source);
}
@@ -335,7 +336,8 @@ public sealed class CameraManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
@@ -460,7 +462,8 @@ public sealed class CameraManager : IDisposable
catch (Exception ex)
{
lock (_gate) session.EffectWorkerRunning = false;
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
+7 -3
View File
@@ -1,6 +1,8 @@
using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
@@ -54,7 +56,8 @@ public class ChannelStatsService
// (insufficientPermissions / quotaExceeded / …) — the bare code was
// undiagnosable for the 2026-09-23 auditDetails 403.
var errorBody = await response.Content.ReadAsStringAsync();
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}");
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
@@ -107,7 +110,8 @@ public class ChannelStatsService
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return 0;
}
@@ -129,4 +133,4 @@ public class ChannelStatsService
}
return count;
}
}
}
+13 -10
View File
@@ -103,7 +103,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess", AppLogErrorClass.Class2);
lock (_gate) _options = null;
throw;
}
@@ -175,7 +175,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop", AppLogErrorClass.Class2);
}
try
@@ -186,12 +186,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (OperationCanceledException)
{
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing",
AppLogMessageType.Error, AppLogErrorClass.Class2);
process!.Kill();
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed", AppLogErrorClass.Class2);
process!.Kill();
}
@@ -201,7 +202,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop", AppLogErrorClass.Class2);
}
process.Dispose();
@@ -263,7 +264,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted", AppLogErrorClass.Class2);
}
finally
{
@@ -274,7 +275,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted", AppLogErrorClass.Class2);
}
});
}
@@ -303,7 +304,8 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
AppLog.Write($"FFmpeg encoder: encoder probe failed — falling back to software: {ex}",
AppLogMessageType.Warning);
return FfmpegEncoderPicker.Preference[^1];
}
}
@@ -347,14 +349,15 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
// The old `code != 0` gate let ffmpeg's clean early exit vanish
// without a word while the pump kept feeding a corpse
// (2026-09-01 first real recording: died at ~0.9s, zero logs).
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})");
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_health.LastError = $"FFmpeg exited with code {exitCode}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
}
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted", AppLogErrorClass.Class2);
}
});
}
+6 -3
View File
@@ -79,7 +79,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
return false;
}
@@ -109,7 +110,8 @@ public sealed class ScreenCaptureManager : IDisposable
lock (_gate)
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
return false;
@@ -189,7 +191,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
+22 -10
View File
@@ -2,6 +2,8 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using ytLive.Models;
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
@@ -132,7 +134,8 @@ public class YouTubeStreamService
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the update ({(int)response.StatusCode})";
}
@@ -170,7 +173,8 @@ public class YouTubeStreamService
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
@@ -194,7 +198,8 @@ public class YouTubeStreamService
}
catch (Exception ex)
{
AppLog.Write($"Broadcast privacy read failed: {ex.Message}");
AppLog.Write($"Broadcast privacy read failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
}
@@ -220,16 +225,19 @@ public class YouTubeStreamService
var broadcast = await _http.DeleteAsync($"{ApiBase}/liveBroadcasts?id={id}");
if (broadcast.IsSuccessStatusCode) return null;
var bBody = await broadcast.Content.ReadAsStringAsync();
AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}");
AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube refused to delete the broadcast ({(int)broadcast.StatusCode})";
}
var body = await video.Content.ReadAsStringAsync();
AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}");
AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube refused to delete the video ({(int)video.StatusCode})";
}
catch (Exception ex)
{
AppLog.Write($"Broadcast delete failed: {ex.Message}");
AppLog.Write($"Broadcast delete failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return ex.Message;
}
}
@@ -306,7 +314,8 @@ public class YouTubeStreamService
}
catch (Exception ex)
{
AppLog.Write($"Broadcast diag failed: {ex.Message}");
AppLog.Write($"Broadcast diag failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
}
@@ -341,7 +350,8 @@ public class YouTubeStreamService
var response = await _http.GetAsync($"{ApiBase}/{pathAndQuery}");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})");
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return default;
}
return JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
@@ -401,7 +411,8 @@ public class YouTubeStreamService
var body = await response.Content.ReadAsStringAsync();
var reason = ErrorReason(body);
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}");
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return BindFailureMessage((int)response.StatusCode, reason);
}
@@ -612,7 +623,8 @@ public class YouTubeStreamService
}
var errorBody = await response.Content.ReadAsStringAsync();
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the message ({(int)response.StatusCode})";
}
+24
View File
@@ -178,6 +178,30 @@
<Setter Property="Margin" Value="10,0,0,6"/>
</Style>
<Style x:Key="AppLogEntryText" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceVariant}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Success}">
<Setter Property="Foreground" Value="{StaticResource Brush.Positive}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Warning}">
<Setter Property="Foreground" Value="{StaticResource Brush.Secondary}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Error}">
<Setter Property="Foreground" Value="{StaticResource Brush.Error}"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Error}"/>
<Condition Binding="{Binding ErrorClass}" Value="{x:Static Helpers:AppLogErrorClass.Class2}"/>
</MultiDataTrigger.Conditions>
<Setter Property="Foreground" Value="{StaticResource Brush.Attention}"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
<!-- A property-panel action: bordered, quiet, 11px normal weight. The accent-filled YtButton
(white semi-bold 13px) made secondary actions like "Reset to built-in" the loudest text in
the panel, which is backwards. -->
+1 -1
View File
@@ -5,7 +5,7 @@ memory-map conventions.
| File | Purpose |
|------|---------|
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`/`YtComboBoxItem` and `YtQualityComboBoxItem` (paid tiers dim but remain clickable), `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`/`YtComboBoxItem` and `YtQualityComboBoxItem` (paid tiers dim but remain clickable), `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader`, and `AppLogEntryText` (wraps and colors by message type and explicit error class) |
How it's wired:
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
+57 -8
View File
@@ -9,11 +9,15 @@ public partial class MainViewModel
{
private const int VisibleLogEntryLimit = 500;
private readonly ObservableCollection<AppLogEntry> _currentLaunchLogEntries = new();
private readonly ObservableCollection<AppLogEntry> _pinnedLogEntries = new();
private DispatcherTimer? _currentLaunchLogTimer;
private long _lastCurrentLaunchLogSequence;
private bool _isLogsTabSelected;
public ObservableCollection<AppLogEntry> CurrentLaunchLogEntries => _currentLaunchLogEntries;
public ObservableCollection<AppLogEntry> PinnedLogEntries => _pinnedLogEntries;
public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0;
public RelayCommand<AppLogEntry> AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry);
public bool IsLiveChatTabSelected
{
@@ -41,12 +45,19 @@ public partial class MainViewModel
private void InitializeCurrentLaunchLogs()
{
foreach (var entry in AppLog.SnapshotCurrentLaunch()
.OrderByDescending(entry => entry.Sequence)
.Take(VisibleLogEntryLimit))
_currentLaunchLogEntries.Add(entry);
var entries = AppLog.SnapshotCurrentLaunch()
.OrderByDescending(entry => entry.Sequence)
.Take(VisibleLogEntryLimit)
.ToArray();
foreach (var entry in entries)
{
if (entry.ErrorClass.HasValue)
InsertPinnedLogEntry(entry);
else
_currentLaunchLogEntries.Add(entry);
}
_lastCurrentLaunchLogSequence = _currentLaunchLogEntries.FirstOrDefault()?.Sequence ?? 0;
_lastCurrentLaunchLogSequence = entries.FirstOrDefault()?.Sequence ?? 0;
_currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
}
@@ -83,13 +94,51 @@ public partial class MainViewModel
foreach (var entry in newEntries)
{
_currentLaunchLogEntries.Insert(0, entry);
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
if (entry.ErrorClass.HasValue)
InsertPinnedLogEntry(entry);
else
InsertChronologicalLogEntry(entry);
_lastCurrentLaunchLogSequence = entry.Sequence;
}
}
private void InsertPinnedLogEntry(AppLogEntry entry)
{
var index = 0;
while (index < _pinnedLogEntries.Count)
{
var current = _pinnedLogEntries[index];
var currentClass = (int)current.ErrorClass!.Value;
var entryClass = (int)entry.ErrorClass!.Value;
if (currentClass < entryClass
|| (currentClass == entryClass && current.Sequence > entry.Sequence))
index++;
else
break;
}
_pinnedLogEntries.Insert(index, entry);
OnPropertyChanged(nameof(HasPinnedLogEntries));
}
private void AcknowledgeLogEntry(AppLogEntry? entry)
{
if (entry?.ErrorClass == null || !_pinnedLogEntries.Remove(entry)) return;
InsertChronologicalLogEntry(entry);
OnPropertyChanged(nameof(HasPinnedLogEntries));
}
private void InsertChronologicalLogEntry(AppLogEntry entry)
{
var index = 0;
while (index < _currentLaunchLogEntries.Count
&& _currentLaunchLogEntries[index].Sequence > entry.Sequence)
index++;
_currentLaunchLogEntries.Insert(index, entry);
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
}
private void StopCurrentLaunchLogFeed()
=> _currentLaunchLogTimer?.Stop();
}
+7 -5
View File
@@ -29,7 +29,7 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
AppLog.Write(ex, "Recording: failed to prepare output path", AppLogErrorClass.Class2);
return null;
}
}
@@ -45,7 +45,8 @@ public partial class MainViewModel : ViewModelBase
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
AppLog.Write($"Recording: output not found for rename: {startPath}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return;
}
_recordLength = _liveElapsed;
@@ -64,7 +65,7 @@ public partial class MainViewModel : ViewModelBase
switch (result.Outcome)
{
case RecordingSaveOutcome.Saved:
AppLog.Write($"Recording saved: {result.FinalPath}");
AppLog.WriteSuccess($"Recording saved: {result.FinalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(result.FinalPath!) +
" — you can rename it in your videos folder at any time.");
@@ -77,7 +78,8 @@ public partial class MainViewModel : ViewModelBase
case RecordingSaveOutcome.DiscardFailed:
// A "discarded" recording still sitting on disk is worse than no
// report at all — name the file and the folder so it can be found.
AppLog.Write($"Recording discard failed: {startPath}");
AppLog.Write($"Recording discard failed: {startPath}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Recording discarded?",
$"Could not delete {Path.GetFileName(startPath)} — it is still in {dir}.");
break;
@@ -85,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
AppLog.Write(ex, "Recording: rename-on-stop failed", AppLogErrorClass.Class2);
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
@@ -159,7 +159,8 @@ public partial class MainViewModel : ViewModelBase
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
AppLog.Write("Reusable stream unavailable; check the OAuth session");
AppLog.Write("Reusable stream unavailable; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
@@ -174,12 +175,13 @@ public partial class MainViewModel : ViewModelBase
_broadcastSeenLive = false;
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
AppLog.Write("Broadcast creation failed; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StopStream(); // full rollback
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
AppLog.WriteSuccess($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
@@ -198,7 +200,8 @@ public partial class MainViewModel : ViewModelBase
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
if (bindError != null)
{
AppLog.Write($"Stream binding failed: {bindError}");
AppLog.Write($"Stream binding failed: {bindError}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
// Show the real reason. "Check the sign-in" would be wrong for five of the
// six documented bind failures, and would hide our own ordering bug behind
// a plausible excuse — see BindFailureMessage.
@@ -216,7 +219,8 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
AppLog.Write($"Go-live preparation failed: {ex.Message}");
AppLog.Write($"Go-live preparation failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", ex.Message);
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
return;
@@ -242,12 +246,14 @@ public partial class MainViewModel : ViewModelBase
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
AppLog.Write("Could not retrieve liveChatId; chat polling disabled",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
// TASK 41: a test broadcast hands its session to the Test drawer once
@@ -285,7 +291,8 @@ public partial class MainViewModel : ViewModelBase
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
AppLog.Write("Stream health poll failed; check the OAuth session");
AppLog.Write("Stream health poll failed; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return;
}
CurrentHealth.HealthStatus = health.HealthStatus;
@@ -303,7 +310,8 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
AppLog.Write($"Stream health poll failed: {ex.Message}");
AppLog.Write($"Stream health poll failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
@@ -375,7 +383,8 @@ public partial class MainViewModel : ViewModelBase
{
var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
if (closeOutError != null)
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
_currentBroadcastId = null;
@@ -435,7 +444,7 @@ public partial class MainViewModel : ViewModelBase
// with IsRecording=true and no encoder (first real launch proved it). The
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
// loop closed, recording finalized, close-out if live), then explains itself.
AppLog.Write($"Frame pump failed: {message}");
AppLog.Write($"Frame pump failed: {message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
void Handle()
{
var live = IsLive;
@@ -567,7 +576,8 @@ public partial class MainViewModel : ViewModelBase
_notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
break;
case PrivateBroadcastOutcome.DeleteFailed:
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}");
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't delete the broadcast",
$"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
break;
+1 -1
View File
@@ -21,7 +21,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and the bounded current-launch log collection; refreshes the process-local AppLog snapshot while Logs is selected and stops its dispatcher timer on shutdown. |
| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and bounded current-launch log collections; refreshes the process-local AppLog snapshot while Logs is selected, pins Class 1 before Class 2 until acknowledged, then returns entries to the LIFO timeline; stops its dispatcher timer on shutdown. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
+1 -1
View File
@@ -103,7 +103,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only, newest-first view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
+69 -2
View File
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Helpers;
@@ -38,6 +40,8 @@ public sealed class LogsPanelTests
window.Show();
window.UpdateLayout();
var vm = (MainViewModel)window.DataContext;
vm.EditingActive = false;
window.UpdateLayout();
var panel = (UserControl)window.FindName("LeftPanel")!;
var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!;
var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!;
@@ -46,16 +50,22 @@ public sealed class LogsPanelTests
Assert.True(vm.IsLiveChatTabSelected);
Assert.True(chatTab.IsChecked);
Assert.False(logsTab.IsChecked);
Assert.Equal(System.Windows.Visibility.Collapsed, logList.Visibility);
Assert.False(logList.IsVisible);
vm.IsLogsTabSelected = true;
window.UpdateLayout();
Assert.True(logsTab.IsChecked);
Assert.False(chatTab.IsChecked);
Assert.Equal(System.Windows.Visibility.Visible, logList.Visibility);
Assert.True(logList.IsVisible);
AppLog.Write("LogsPanel older marker");
AppLog.Write("LogsPanel newer marker");
AppLog.WriteWarning("LogsPanel warning marker");
AppLog.WriteSuccess("LogsPanel success marker");
AppLog.Write("LogsPanel class 2 marker " + new string('2', 240),
AppLogMessageType.Error, AppLogErrorClass.Class2);
AppLog.Write("LogsPanel class 1 marker " + new string('1', 240),
AppLogMessageType.Error, AppLogErrorClass.Class1);
vm.RefreshCurrentLaunchLogs();
window.UpdateLayout();
@@ -66,6 +76,47 @@ public sealed class LogsPanelTests
Assert.True(newerIndex < olderIndex, "the Logs tab must put the newest entry first");
Assert.DoesNotContain(entries, text => text.Contains("previous-launch-sentinel", StringComparison.Ordinal));
Assert.Same(vm.CurrentLaunchLogEntries, logList.ItemsSource);
var class1 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal));
var class2 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal));
Assert.True(Array.IndexOf(vm.PinnedLogEntries.ToArray(), class1)
< Array.IndexOf(vm.PinnedLogEntries.ToArray(), class2));
Assert.Equal("CLASS 1", class1.ErrorClassLabel);
Assert.Equal("CLASS 2", class2.ErrorClassLabel);
Assert.Equal("CLASS 1", class1.TypeLabel);
var pinnedPanel = (ItemsControl)panel.FindName("PinnedLogPanel")!;
Assert.Equal(System.Windows.Visibility.Visible, pinnedPanel.Visibility);
var class1Text = Find(pinnedPanel, element => element is TextBlock text
&& text.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal)) as TextBlock;
var class2Text = Find(pinnedPanel, element => element is TextBlock text
&& text.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal)) as TextBlock;
Assert.NotNull(class1Text);
Assert.NotNull(class2Text);
Assert.Equal(TextWrapping.Wrap, class1Text!.TextWrapping);
Assert.Equal(BrushColor("Brush.Error"), Assert.IsType<SolidColorBrush>(class1Text.Foreground).Color);
Assert.Equal(BrushColor("Brush.Attention"), Assert.IsType<SolidColorBrush>(class2Text!.Foreground).Color);
var warningText = Find(logList, element => element is TextBlock text
&& text.Text.Contains("LogsPanel warning marker", StringComparison.Ordinal)) as TextBlock;
var successText = Find(logList, element => element is TextBlock text
&& text.Text.Contains("LogsPanel success marker", StringComparison.Ordinal)) as TextBlock;
var infoText = Find(logList, element => element is TextBlock text
&& text.Text.Contains("LogsPanel newer marker", StringComparison.Ordinal)) as TextBlock;
Assert.NotNull(warningText);
Assert.NotNull(successText);
Assert.NotNull(infoText);
Assert.Equal(BrushColor("Brush.Secondary"), Assert.IsType<SolidColorBrush>(warningText!.Foreground).Color);
Assert.Equal(BrushColor("Brush.Positive"), Assert.IsType<SolidColorBrush>(successText!.Foreground).Color);
Assert.Equal(BrushColor("Brush.OnSurfaceVariant"), Assert.IsType<SolidColorBrush>(infoText!.Foreground).Color);
var class1Ack = Find(pinnedPanel, element => element is Button button
&& button.DataContext is AppLogEntry entry && entry.Sequence == class1.Sequence) as Button;
Assert.NotNull(class1Ack?.Command);
Assert.Same(class1, class1Ack!.CommandParameter);
class1Ack!.Command!.Execute(class1Ack.CommandParameter);
Assert.DoesNotContain(class1, vm.PinnedLogEntries);
Assert.Contains(class1, vm.CurrentLaunchLogEntries);
}
finally
{
@@ -81,4 +132,20 @@ public sealed class LogsPanelTests
try { Directory.Delete(directory, recursive: true); } catch { }
}
}
private static System.Windows.Media.Color BrushColor(string key)
=> Assert.IsType<SolidColorBrush>(Application.Current!.FindResource(key)).Color;
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
{
if (predicate(parent)) return parent;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Find(child, predicate);
if (found != null) return found;
}
return null;
}
}