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:
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user