Resolution dropdown, list-focus preservation, unified dark theme, startup logging, and memory map

- Bottom-bar resolution dropdown (1080p60/1080p30/720p60/480p30) with tooltip
  on finding upload bandwidth; disabled while live; no in-app speed test
- FocusPreservingListBox keeps selection/focus coherent when a selected
  scene/source is deleted (skip hidden scenes; leave list when empty)
- Themes/Controls.xaml: single dark-theme dictionary merged once in App.xaml;
  custom ComboBox template fixes SelectionBoxItem rendering; GoLiveWindow and
  ReuseImageDialog consolidated onto shared styles
- AppLog file logger + AppDomain/Dispatcher exception hooks; checkpointed
  MainWindow/VM/dialog constructors (caught MenuItemRole.Separator XAML crash)
- Memory map: schema.md conventions, per-directory index.md, updated ai.md/
  TASKS.md/README.md (OAuth creds real; token persistence still pending)
This commit is contained in:
2026-08-06 08:33:05 -07:00
parent 7dc8490d96
commit a74162e34e
22 changed files with 876 additions and 230 deletions
+40
View File
@@ -0,0 +1,40 @@
using System.IO;
namespace ytLive.Helpers;
/// <summary>
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
/// a hard crash.
/// </summary>
public static class AppLog
{
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"startup.log");
private static readonly object Gate = new();
public static void Write(string message)
{
try
{
lock (Gate)
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
}
}
catch
{
// Never let logging itself crash the app.
}
}
public static void Write(Exception exception, string message)
{
Write($"{message}: {exception}");
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Specialized;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
namespace ytLive.Helpers;
/// <summary>
/// A ListBox that keeps selection and keyboard focus coherent when the
/// selected item is deleted. Focus lands on the item now at the deleted
/// position (the previous item when the last one was removed) or leaves
/// the box entirely when it becomes empty. Drag-to-reorder is untouched.
/// </summary>
public class FocusPreservingListBox : ListBox
{
/// <summary>
/// Predicate deciding which items the delete-focus fallback may land on
/// (e.g. skip hidden scenes). Null means every item is eligible.
/// </summary>
public Func<object, bool>? IsSelectable { get; set; }
protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(e);
if (e.Action != NotifyCollectionChangedAction.Remove) return;
var hadFocus = IsKeyboardFocusWithin || IsKeyboardFocused;
if (!hadFocus) return;
if (Mouse.Captured == this) return;
if (Items.Count == 0)
{
SelectedItem = null;
MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
return;
}
var targetIndex = FindSelectableIndex(Math.Clamp(e.OldStartingIndex, 0, Items.Count - 1));
if (targetIndex < 0)
{
SelectedItem = null;
return;
}
SelectedIndex = targetIndex;
var item = Items[targetIndex];
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () =>
{
ScrollIntoView(item);
if (ItemContainerGenerator.ContainerFromItem(item) is ListBoxItem container)
container.Focus();
});
}
private int FindSelectableIndex(int start)
{
for (var i = start; i >= 0; i--)
if (IsItemSelectable(i)) return i;
for (var i = start + 1; i < Items.Count; i++)
if (IsItemSelectable(i)) return i;
return -1;
}
private bool IsItemSelectable(int index)
{
if (index < 0 || index >= Items.Count) return false;
var item = Items[index];
return item != null && (IsSelectable?.Invoke(item) ?? true);
}
}
+17
View File
@@ -0,0 +1,17 @@
# Helpers — index
Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `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) |
| `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) |
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
class backs; [`Models/index.md`](../Models/index.md) and
[`ViewModels/index.md`](../ViewModels/index.md) for the objects it works on.