feat(ui): add current-launch logs tab

Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
This commit is contained in:
2026-11-02 11:06:43 -08:00
parent 20f059422b
commit 9fa06c67c2
11 changed files with 305 additions and 77 deletions
+85 -57
View File
@@ -913,69 +913,97 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="{StaticResource Brush.OnSurfaceDisabled}" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<ToggleButton x:Name="LiveChatTabButton" Style="{StaticResource SegmentToggle}"
IsChecked="{Binding IsLiveChatTabSelected, Mode=TwoWay}" Padding="10,5">
<TextBlock Text="Live Chat" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton x:Name="LogsTabButton" Style="{StaticResource SegmentToggle}"
IsChecked="{Binding IsLogsTabSelected, Mode=TwoWay}" Padding="10,5" Margin="4,0,0,0">
<TextBlock Text="Logs" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
<!-- Pinned message -->
<Border Grid.Row="1" VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}" BorderBrush="{StaticResource Brush.Tertiary}" BorderThickness="1"
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
<StackPanel>
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="{StaticResource Brush.Tertiary}" FontWeight="SemiBold" FontSize="11"/>
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<Grid Visibility="{Binding IsLiveChatTabSelected, Converter={StaticResource BoolToVis}}">
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="{StaticResource Brush.OnSurfaceDisabled}" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
</StackPanel>
<!-- Message feed (shown when live) -->
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0" Margin="0,40,0,0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Secondary}"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<!-- Pinned message -->
<Border VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}" BorderBrush="{StaticResource Brush.Tertiary}" BorderThickness="1"
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="{StaticResource Brush.Tertiary}"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="{StaticResource Brush.Secondary}" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="{StaticResource Brush.Secondary}"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="{StaticResource Brush.OnSurface}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="{StaticResource Brush.Tertiary}" FontWeight="SemiBold" FontSize="11"/>
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</ListBox>
<!-- Message feed (shown when live) -->
<ListBox Background="Transparent" BorderThickness="0" Margin="0,40,0,0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Secondary}"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="{StaticResource Brush.Tertiary}"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="{StaticResource Brush.Secondary}" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="{StaticResource Brush.Secondary}"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="{StaticResource Brush.OnSurface}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</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>
</Grid>
</Grid>
</Border>
+3 -1
View File
@@ -19,7 +19,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.
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.
## Window ↔ control contract
+13 -16
View File
@@ -1,27 +1,24 @@
# HANDOFF — 2026-11-02 (scene-local alert ticker)
# HANDOFF — 2026-11-02 (current-launch Logs tab)
## State
- `main`; ticker work is local and not pushed.
- Standalone `SourceType.Ticker` is addable once per scene, has per-source settings, and shows the
in-memory current + queued alert feed. It does not persist event history.
- Stream Alerts keeps one box per layout; its ticker shares `Ticker` settings but shows only the
active event. Existing per-Source columns persist the settings without a schema migration.
- Focused build succeeded; the new integration test and all `AlertTickerPreviewDisplayTests` pass.
- `main`; scene-local ticker commit `20f0594` is local, not pushed.
- Logs tab work is in progress: non-edit panel has Live Chat first/default and Logs second. Logs
reads only the process-local AppLog buffer (newest 500, newest first); file logging and rotation
remain unchanged, and prior-launch file contents are not loaded.
- The Logs-panel integration test passes; clean app build is 0 warnings and scope check passes.
## Next action
Next queued unit: Day 1 system-message strip; also check the queued Debug-only test-access rule.
Keep one integration test per change. Do not push absent a milestone/checkpoint request.
Run the declared-scope verification and commit the Logs-tab unit. Then resolve the outstanding
UI/content test failures one issue at a time, with a separate local commit for each resolved task.
Do not push absent a milestone/checkpoint request.
## Landmines
- Build/test with Windows `dotnet.exe`; the full suite can hang in WASAPI teardown.
- Focused ticker tests pass; clean app build is 0 warnings. Full suite reports 13–14 UI/content
failures (508/521 or 507/521 pass), matching the prior baseline note; none is a ticker test. Their
relationship to this change is unconfirmed, and `verify.sh` currently only allows two other tests.
- The integration assertion is in `ytLive.Tests/AlertTickerPreviewDisplayTests.cs` and must use
`LayoutPathOverride`/clear it in `finally` if it constructs `MainWindow`.
- Scene tickers are dynamic per-element sources; only the AlertBox ticker uses the pump's global
ticker overlay callback.
- Full suite after Logs changes: 13 failed / 509 passed of 522; the same UI/content failures remain
(none in Logs or ticker tests). They are the next queued work, not to be masked or added here.
- Any integration test constructing `MainWindow` must set `LayoutPathOverride` to a temporary DB
and clear it in `finally`; AppLog path overrides are also process-wide and are serialized here.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
+19 -1
View File
@@ -12,6 +12,7 @@ public static class AppLog
{
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
private const long DefaultMaxBytes = 5 * 1024 * 1024;
private const int CurrentLaunchEntryLimit = 500;
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
private const int RetainedFiles = 1;
@@ -20,6 +21,8 @@ public static class AppLog
InstanceProfile.DataRoot, "startup.log");
private static readonly object Gate = new();
private static readonly List<AppLogEntry> CurrentLaunchEntries = new();
private static long _nextSequence;
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
internal static string? PathOverride { get; set; }
@@ -29,16 +32,29 @@ public static class AppLog
private static string LogPath => PathOverride ?? DefaultLogPath;
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
{
lock (Gate)
return CurrentLaunchEntries.ToArray();
}
public static void Write(string message)
{
try
{
lock (Gate)
{
var entry = new AppLogEntry(
++_nextSequence,
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}");
CurrentLaunchEntries.Add(entry);
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
CurrentLaunchEntries.RemoveAt(0);
var path = LogPath;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
}
}
catch
@@ -52,3 +68,5 @@ public static class AppLog
Write($"{message}: {exception}");
}
}
public sealed record AppLogEntry(long Sequence, string Text);
+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 (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
| `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 |
| `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) |
+95
View File
@@ -0,0 +1,95 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using ytLive.Helpers;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private const int VisibleLogEntryLimit = 500;
private readonly ObservableCollection<AppLogEntry> _currentLaunchLogEntries = new();
private DispatcherTimer? _currentLaunchLogTimer;
private long _lastCurrentLaunchLogSequence;
private bool _isLogsTabSelected;
public ObservableCollection<AppLogEntry> CurrentLaunchLogEntries => _currentLaunchLogEntries;
public bool IsLiveChatTabSelected
{
get => !_isLogsTabSelected;
set
{
if (value)
SelectLogsTab(false);
else if (!_isLogsTabSelected)
OnPropertyChanged(nameof(IsLiveChatTabSelected));
}
}
public bool IsLogsTabSelected
{
get => _isLogsTabSelected;
set
{
if (value)
SelectLogsTab(true);
else if (_isLogsTabSelected)
OnPropertyChanged(nameof(IsLogsTabSelected));
}
}
private void InitializeCurrentLaunchLogs()
{
foreach (var entry in AppLog.SnapshotCurrentLaunch()
.OrderByDescending(entry => entry.Sequence)
.Take(VisibleLogEntryLimit))
_currentLaunchLogEntries.Add(entry);
_lastCurrentLaunchLogSequence = _currentLaunchLogEntries.FirstOrDefault()?.Sequence ?? 0;
_currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
}
private void SelectLogsTab(bool selected)
{
if (_isLogsTabSelected == selected)
{
OnPropertyChanged(nameof(IsLiveChatTabSelected));
OnPropertyChanged(nameof(IsLogsTabSelected));
return;
}
_isLogsTabSelected = selected;
OnPropertyChanged(nameof(IsLiveChatTabSelected));
OnPropertyChanged(nameof(IsLogsTabSelected));
if (selected)
{
RefreshCurrentLaunchLogs();
_currentLaunchLogTimer?.Start();
}
else
{
_currentLaunchLogTimer?.Stop();
}
}
internal void RefreshCurrentLaunchLogs()
{
var newEntries = AppLog.SnapshotCurrentLaunch()
.Where(entry => entry.Sequence > _lastCurrentLaunchLogSequence)
.OrderBy(entry => entry.Sequence)
.ToArray();
foreach (var entry in newEntries)
{
_currentLaunchLogEntries.Insert(0, entry);
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
_lastCurrentLaunchLogSequence = entry.Sequence;
}
}
private void StopCurrentLaunchLogFeed()
=> _currentLaunchLogTimer?.Stop();
}
+2
View File
@@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
InitializeCurrentLaunchLogs();
IsAppOwned = _entitlement.IsAppOwned;
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
@@ -524,6 +525,7 @@ public partial class MainViewModel : ViewModelBase
public void Shutdown()
{
StopCurrentLaunchLogFeed();
_saveDebounce?.Stop();
SaveLayoutNow();
_fullScreenDetector.StopWatching();
+1
View File
@@ -21,6 +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. |
| `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 = YouTube chat. 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, 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.
- **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
View File
@@ -13,6 +13,7 @@ namespace ytLive.Tests;
/// <c>startup.1.log</c>, the live file starts fresh with the newest entry, and the SET of
/// files stays bounded — a long stream cannot grow the log without limit. (The old
/// behaviour appended forever; the file was 23 MB and growing every few seconds.)</para></summary>
[Collection("RealApp")]
public class AppLogRotationTests
{
[Fact]
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.IO;
using System.Linq;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class LogsPanelTests
{
private readonly RealAppHost _app;
public LogsPanelTests(RealAppHost app) => _app = app;
[Fact]
public void NonEditPanel_DefaultsToLiveChat_AndShowsCurrentLaunchLogsNewestFirst()
{
var directory = Path.Combine(Path.GetTempPath(), $"ytlive-logs-panel-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var databasePath = Path.Combine(directory, "layout.db");
var logPath = Path.Combine(directory, "startup.log");
File.WriteAllText(logPath, "previous-launch-sentinel" + Environment.NewLine);
AppLog.PathOverride = logPath;
MainViewModel.LayoutPathOverride = databasePath;
try
{
_app.Run(() =>
{
var window = new MainWindow();
try
{
window.Show();
window.UpdateLayout();
var vm = (MainViewModel)window.DataContext;
var panel = (UserControl)window.FindName("LeftPanel")!;
var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!;
var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!;
var logList = (ListBox)panel.FindName("LogListBox")!;
Assert.True(vm.IsLiveChatTabSelected);
Assert.True(chatTab.IsChecked);
Assert.False(logsTab.IsChecked);
Assert.Equal(System.Windows.Visibility.Collapsed, logList.Visibility);
vm.IsLogsTabSelected = true;
window.UpdateLayout();
Assert.True(logsTab.IsChecked);
Assert.False(chatTab.IsChecked);
Assert.Equal(System.Windows.Visibility.Visible, logList.Visibility);
AppLog.Write("LogsPanel older marker");
AppLog.Write("LogsPanel newer marker");
vm.RefreshCurrentLaunchLogs();
window.UpdateLayout();
var entries = vm.CurrentLaunchLogEntries.Select(entry => entry.Text).ToArray();
var newerIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel newer marker", StringComparison.Ordinal));
var olderIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel older marker", StringComparison.Ordinal));
Assert.True(newerIndex >= 0 && olderIndex >= 0, "new log writes did not reach the Logs tab");
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);
}
finally
{
window.Close();
}
});
}
finally
{
MainViewModel.LayoutPathOverride = null;
AppLog.PathOverride = null;
SqliteConnection.ClearAllPools();
try { Directory.Delete(directory, recursive: true); } catch { }
}
}
}