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
+5 -1
View File
@@ -4,6 +4,10 @@
xmlns:local="clr-namespace:ytLive"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+25 -4
View File
@@ -1,6 +1,6 @@
using System.Configuration;
using System.Data;
using System.Windows;
using System.Windows;
using System.Windows.Threading;
using ytLive.Helpers;
namespace ytLive;
@@ -9,5 +9,26 @@ namespace ytLive;
/// </summary>
public partial class App : Application
{
}
public App()
{
AppLog.Write("App ctor");
}
protected override void OnStartup(StartupEventArgs e)
{
// The app is a WinExe (no visible console) and is often debugged from
// WSL where it can't even run, so startup checkpoints and any unhandled
// exception are written to %APPDATA%\ytLlive\startup.log via AppLog.
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
AppLog.Write(args.ExceptionObject is Exception ex
? "AppDomain unhandled exception: " + ex
: "AppDomain unhandled exception object: " + args.ExceptionObject);
DispatcherUnhandledException += (_, args) =>
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
AppLog.Write("App OnStartup begin");
base.OnStartup(e);
AppLog.Write("App OnStartup end");
}
}
+10 -59
View File
@@ -7,58 +7,9 @@
ShowInTaskbar="False" Background="#1a1a2e">
<Window.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="GoLiveButton" TargetType="Button">
<Setter Property="Background" Value="#e94560"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
<Setter Property="Background" Value="#16213e"/>
</Style>
<Style x:Key="GoLiveLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#a0a0b0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style x:Key="GoLiveTextBox" TargetType="TextBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="CaretBrush" Value="White"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
<Style x:Key="GoLiveCombo" TargetType="ComboBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Margin" Value="0,0,0,12"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
</Style>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="24">
@@ -77,17 +28,17 @@
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
<TextBlock Text="Title" Style="{StaticResource GoLiveLabel}"/>
<TextBox Style="{StaticResource GoLiveTextBox}"
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Description" Style="{StaticResource GoLiveLabel}"/>
<TextBox Style="{StaticResource GoLiveTextBox}" Height="60" AcceptsReturn="True"
<TextBlock Text="Description" Style="{StaticResource YtLabel}"/>
<TextBox Style="{StaticResource YtTextBox}" Height="60" AcceptsReturn="True" Margin="0,0,0,12"
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Visibility" Style="{StaticResource GoLiveLabel}"/>
<ComboBox Style="{StaticResource GoLiveCombo}"
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,12"
ItemsSource="{Binding Visibilities}"
SelectedItem="{Binding Visibility}"/>
</StackPanel>
@@ -95,9 +46,9 @@
<!-- ACTIONS -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Start Stream" Command="{Binding StartCommand}"
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
Style="{StaticResource YtButton}" MinWidth="110"/>
</StackPanel>
</Grid>
</Window>
+3
View File
@@ -1,4 +1,5 @@
using System.Windows;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive;
@@ -7,7 +8,9 @@ public partial class GoLiveWindow : Window
{
public GoLiveWindow(GoLiveViewModel viewModel)
{
AppLog.Write("GoLiveWindow ctor: before InitializeComponent");
InitializeComponent();
AppLog.Write("GoLiveWindow ctor: after InitializeComponent");
DataContext = viewModel;
viewModel.StartRequested += () => DialogResult = true;
viewModel.CancelRequested += () => DialogResult = false;
+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.
+24 -100
View File
@@ -4,6 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ytLive.ViewModels"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="720" Width="1280"
MinWidth="1180" MinHeight="600"
@@ -11,6 +12,7 @@
Background="#1a1a2e"
WindowStartupLocation="CenterScreen"
Closing="MainWindow_Closing"
Loaded="MainWindow_Loaded"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
<Window.DataContext>
@@ -22,6 +24,7 @@
</Window.TaskbarItemInfo>
<Window.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
@@ -64,55 +67,6 @@
</DrawingImage.Drawing>
</DrawingImage>
<!-- Base button style -->
<Style x:Key="YtButton" TargetType="Button">
<Setter Property="Background" Value="#e94560"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtButtonSecondary" TargetType="Button" BasedOn="{StaticResource YtButton}">
<Setter Property="Background" Value="#16213e"/>
</Style>
<Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#26ffffff"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Eye icon: open when visible, slashed when hidden -->
<Style x:Key="EyeIconStyle" TargetType="Path">
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
@@ -131,36 +85,7 @@
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="YtTextBox" TargetType="TextBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="CaretBrush" Value="White"/>
</Style>
<Style x:Key="YtComboBox" TargetType="ComboBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
</Style>
<Style x:Key="YtLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#a0a0b0"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style x:Key="SectionHeader" TargetType="TextBlock">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
</ResourceDictionary>
</Window.Resources>
<Grid>
@@ -244,7 +169,7 @@
</Button>
</StackPanel>
<ListBox x:Name="SceneList" Grid.Row="1" Background="Transparent" BorderThickness="0"
<Helpers:FocusPreservingListBox x:Name="SceneList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding Scenes}"
SelectedItem="{Binding ActiveScene}"
SelectionChanged="SceneList_SelectionChanged"
@@ -252,8 +177,8 @@
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True">
@@ -261,7 +186,7 @@
</DataTrigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
@@ -326,7 +251,7 @@
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Helpers:FocusPreservingListBox>
<!-- Sources (for active scene) -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,0">
@@ -347,18 +272,18 @@
</Button>
</StackPanel>
<ListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ActiveScene.Sources}"
SelectedItem="{Binding SelectedSource, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListBox.ItemContainerStyle>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
@@ -379,7 +304,7 @@
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="3" Text="No sources yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
@@ -515,6 +440,7 @@
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
Foreground="#e0e0e0" FontSize="12">
<ListBox.ItemTemplate>
@@ -567,7 +493,14 @@
Foreground="#e94560" Margin="20,0,0,0"/>
</StackPanel>
<Button Grid.Column="1" ToolTip="Menu" Style="{StaticResource IconButton}" Click="GearButton_Click">
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<ComboBox Style="{StaticResource YtComboBox}" MinWidth="150"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
DisplayMemberPath="Label"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding IsOffline}"/>
<Button ToolTip="Menu" Style="{StaticResource IconButton}" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Save Layout" Command="{Binding SaveLayoutCommand}"/>
@@ -583,6 +516,7 @@
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
</Button>
</StackPanel>
</Grid>
</Border>
@@ -638,16 +572,6 @@
<TextBlock Text="These prefill the Start Stream dialog. Scene and stream settings are stored on the stream itself."
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
<TextBlock Text="STREAM SETTINGS" Style="{StaticResource SectionHeader}" Margin="0,20,0,8"/>
<TextBlock Text="Stream Quality" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" HorizontalAlignment="Left" MinWidth="140"
ItemsSource="{Binding StreamQualities}"
SelectedItem="{Binding StreamQuality}"/>
<TextBlock Text="Switching quality updates the stream metrics in the status bar. 1080p60 is the standard."
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
</StackPanel>
<!-- Report Bug -->
+11
View File
@@ -4,6 +4,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -15,11 +16,21 @@ public partial class MainWindow : Window
public MainWindow()
{
AppLog.Write("MainWindow ctor: before InitializeComponent");
InitializeComponent();
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
// When a focused scene is deleted, never land focus on a hidden scene.
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
AppLog.Write("MainWindow ctor: end");
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
AppLog.Write("MainWindow loaded");
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+10
View File
@@ -0,0 +1,10 @@
namespace ytLive.Models;
/// <summary>
/// A resolution tier offered by the bottom-bar dropdown. Bitrate is Mbps.
/// </summary>
public sealed record QualityOption(string Display, int Fps, double Bitrate)
{
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
}
+15
View File
@@ -0,0 +1,15 @@
# Models — index
Plain data types. No logic beyond what a property can carry. See
[`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection |
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource`, asset identity |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
+10 -4
View File
@@ -41,8 +41,14 @@ dotnet run
| Path | Role |
|------|------|
| `Models/` | Scene, Source, StreamConfig, StreamHealth, YouTube channel/chat |
| `ViewModels/` | MainViewModel — scenes, stream controls, chat |
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling) |
| `Helpers/` | ViewModelBase, RelayCommand |
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat |
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel |
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite) |
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
## Docs (memory map)
`schema.md` defines the conventions; `ai.md` is the AI/session guide;
`TASKS.md` is the task queue. Each code directory carries an `index.md`.
+5 -27
View File
@@ -7,30 +7,7 @@
ShowInTaskbar="False" Background="#1a1a2e">
<Window.Resources>
<Style x:Key="GoLiveButton" TargetType="Button">
<Setter Property="Background" Value="#e94560"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
<Setter Property="Background" Value="#16213e"/>
</Style>
<ResourceDictionary>
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
@@ -53,6 +30,7 @@
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="24">
@@ -95,11 +73,11 @@
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="New Image…" Command="{Binding NewImageCommand}"
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
Style="{StaticResource YtButton}" MinWidth="110"/>
</StackPanel>
</Grid>
</Window>
+3
View File
@@ -1,4 +1,5 @@
using System.Windows;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive;
@@ -10,7 +11,9 @@ public partial class ReuseImageDialog : Window
public ReuseImageDialog(ReuseImageViewModel viewModel)
{
AppLog.Write("ReuseImageDialog ctor: before InitializeComponent");
InitializeComponent();
AppLog.Write("ReuseImageDialog ctor: after InitializeComponent");
DataContext = viewModel;
viewModel.ReuseRequested += () =>
{
+15
View File
@@ -0,0 +1,15 @@
# Services — index
External-facing logic: YouTube API, persistence. See
[`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. **Complete — but no token persistence yet** (DPAPI planned) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
OAuth credentials live in [`Helpers/OAuthCredentials.cs`](../Helpers/OAuthCredentials.cs).
+5 -2
View File
@@ -1,5 +1,8 @@
# ytLlive — Task List
> Task queue and authoritative research. Memory-map conventions: [`schema.md`](schema.md);
> architecture/decisions: [`ai.md`](ai.md). Update statuses here whenever a task moves.
## YouTube Live API — research facts (authoritative, v3 build)
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
@@ -71,9 +74,9 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
### Status: 🔶 UI flow done — auth wiring pending
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
- Real OAuth2 with the baked-in Google credentials (desktop client type; loopback callback, no redirect registration)
- Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
- ⬜ Token persistence via Windows DPAPI, reload on startup
- ⬜ Account sign-in/change wired to YouTubeAuthService (currently simulated in GoLiveViewModel)
- ⬜ Account sign-in/change surfaced in the GoLive dialog (GoLiveViewModel still simulates it)
---
+458
View File
@@ -0,0 +1,458 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Base button style -->
<Style x:Key="YtButton" TargetType="Button">
<Setter Property="Background" Value="#e94560"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="Glow" Background="White" CornerRadius="4" Opacity="0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Glow" Property="Opacity" Value="0.08"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Glow" Property="Opacity" Value="0.16"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtButtonSecondary" TargetType="Button" BasedOn="{StaticResource YtButton}">
<Setter Property="Background" Value="#16213e"/>
</Style>
<Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#26ffffff"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="YtTextBox" TargetType="TextBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderBrush" Value="#2a3a5e"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="CaretBrush" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4" Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost" Margin="0"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#3d5076"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#e94560"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtComboBox" TargetType="ComboBox">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="BorderBrush" Value="#2a3a5e"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="MinHeight" Value="30"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="ScrollViewer.CanContentScroll" Value="True"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="3" Margin="0,1" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1a3a66"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Chrome" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"/>
<ContentPresenter x:Name="ContentSite" IsHitTestVisible="False"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"/>
<ToggleButton x:Name="ToggleButton" Focusable="False" ClickMode="Press"
Background="Transparent" BorderThickness="0"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Grid Background="Transparent">
<Path x:Name="Chevron" Data="M0,0 L4,4 L8,0" Stroke="#8a93a8"
StrokeThickness="1.5" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Chevron" Property="Stroke" Value="#c8d0e0"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<Popup x:Name="PART_Popup" Placement="Bottom" AllowsTransparency="True"
PopupAnimation="Fade" StaysOpen="False"
IsOpen="{TemplateBinding IsDropDownOpen}">
<Border Background="#16213e" BorderBrush="#2a3a5e" BorderThickness="1"
CornerRadius="5" Padding="2" Margin="0,4,0,0"
SnapsToDevicePixels="True">
<ScrollViewer MaxHeight="{TemplateBinding MaxDropDownHeight}"
ScrollViewer.CanContentScroll="True">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Chrome" Property="Background" Value="#163a6b"/>
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="Chrome" Property="Background" Value="#163a6b"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Chrome" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Thin dark scrollbar -->
<Style x:Key="YtScrollBarPageButton" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Grid Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtScrollBarThumb" TargetType="Thumb">
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="ThumbBd" Background="#2c3c5e" CornerRadius="4" Margin="2"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ThumbBd" Property="Background" Value="#3d5076"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="ThumbBd" Property="Background" Value="#4a5f8a"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Width" Value="10"/>
<Setter Property="MinWidth" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Command="{x:Static ScrollBar.PageUpCommand}"
Style="{StaticResource YtScrollBarPageButton}"/>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="{x:Static ScrollBar.PageDownCommand}"
Style="{StaticResource YtScrollBarPageButton}"/>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource YtScrollBarThumb}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="10"/>
<Setter Property="MinHeight" Value="10"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Slider -->
<Style x:Key="YtSliderTrackButton" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="{Binding Tag, RelativeSource={RelativeSource TemplatedParent}}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtSliderThumb" TargetType="Thumb">
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Ellipse x:Name="Knob" Width="16" Height="16" Fill="#e94560"
Stroke="#ff8fa3" StrokeThickness="1"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Knob" Property="Fill" Value="#ff5b78"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Knob" Property="Fill" Value="#ff8fa3"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Slider">
<Setter Property="Height" Value="20"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">
<Grid VerticalAlignment="Center">
<Border Height="4" CornerRadius="2" Background="#2a3a5e"
VerticalAlignment="Center"/>
<Track x:Name="PART_Track" Height="18">
<Track.DecreaseRepeatButton>
<RepeatButton Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource YtSliderTrackButton}" Tag="#e94560"/>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource YtSliderTrackButton}" Tag="Transparent"/>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource YtSliderThumb}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dark tooltip -->
<Style TargetType="ToolTip">
<Setter Property="Background" Value="#1a1a2e"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderBrush" Value="#2a3a5e"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" MaxWidth="320"
Foreground="#e0e0e0" FontSize="12"/>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dark context menu -->
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="#16213e"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderBrush" Value="#2a3a5e"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="3"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5" Padding="{TemplateBinding Padding}">
<ItemsPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="3"
Padding="{TemplateBinding Padding}" Margin="0,1">
<ContentPresenter ContentSource="Header" VerticalAlignment="Center"
HorizontalAlignment="Left"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Menu separators render as standalone Separator controls -->
<Style TargetType="Separator">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Height="1" Background="#2a3a5e" Margin="8,4"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dark list item (used by all list boxes) -->
<Style x:Key="YtListBoxItem" TargetType="ListBoxItem">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="Padding" Value="4,2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="3" Padding="{TemplateBinding Padding}" Margin="0,1">
<ContentPresenter VerticalAlignment="Center"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#22ffffff"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1a3a66"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#a0a0b0"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style x:Key="SectionHeader" TargetType="TextBlock">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
</ResourceDictionary>
+24
View File
@@ -0,0 +1,24 @@
# Themes — index
The single source of the dark UI theme. See [`schema.md`](../schema.md) for the
memory-map conventions.
| File | Purpose |
|------|---------|
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtComboBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
How it's wired:
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
automatically; **do not re-merge per-window**.
- Palette: window `#1a1a2e`, fields `#0f3460`, borders `#2a3a5e`, popups
`#16213e`, accent `#e94560`, hover `#26ffffff`, text `#e0e0e0`.
Gotchas learned the hard way:
- `MenuItemRole` has **no `Separator` value** — separators are standalone
`Separator` controls with their own implicit style.
- A custom `ComboBox` template must put the `ContentPresenter` (`SelectionBoxItem`)
**inside the ComboBox template, not the ToggleButton template** — the latter
resolves against the wrong TemplatedParent and renders nothing.
Related: [`Helpers/index.md`](../Helpers/index.md) (custom controls the theme
styles), the XAML files at repo root that consume these styles.
+32 -14
View File
@@ -255,30 +255,40 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _defaultStreamVisibility, value);
}
public string[] StreamQualities { get; } = { "1080p60", "1080p30", "720p60" };
// ─── Resolution quality dropdown (bottom bar) ───
// Tiers offered to the creator before going live. First = default. The
// dropdown is disabled while live because YouTube stream resolution is
// immutable after stream creation (see TASKS.md compliance notes).
private string _streamQuality = "1080p60";
public string StreamQuality
public QualityOption[] QualityOptions { get; } =
{
get => _streamQuality;
new("1080p60", 60, 8.0),
new("1080p30", 30, 8.0),
new("720p60", 60, 6.0),
new("480p30", 30, 2.5),
};
public string ResolutionHelp { get; } =
"Pick the highest resolution your upload bandwidth can comfortably sustain. " +
"Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
"then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
private QualityOption _selectedQuality;
public QualityOption SelectedQuality
{
get => _selectedQuality;
set
{
if (SetProperty(ref _streamQuality, value))
if (SetProperty(ref _selectedQuality, value))
ApplyStreamQuality(value);
}
}
private void ApplyStreamQuality(string quality)
private void ApplyStreamQuality(QualityOption quality)
{
var (bitrate, fps) = quality switch
{
"1080p30" => (8.0, 30.0),
"720p60" => (6.0, 60.0),
_ => (8.0, 60.0),
};
var health = CurrentHealth;
health.CurrentBitrate = bitrate;
health.FPS = fps;
health.CurrentBitrate = quality.Bitrate;
health.FPS = quality.Fps;
OnPropertyChanged(nameof(CurrentHealth));
}
@@ -331,9 +341,13 @@ public class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0];
_youtubeChat.MessageReceived += OnChatMessageReceived;
@@ -369,6 +383,7 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
LoadLayout();
AppLog.Write("MainViewModel ctor end");
}
private static string DefaultLayoutPath => Path.Combine(
@@ -381,6 +396,7 @@ public class MainViewModel : ViewModelBase
_isLoading = true;
try
{
AppLog.Write("LoadLayout begin");
var scenes = _layoutStore.Load();
Scenes.Clear();
foreach (var scene in scenes)
@@ -394,6 +410,7 @@ public class MainViewModel : ViewModelBase
AddScene("Chat", isChatScene: true);
AddScene("Ending");
}
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
{
@@ -402,6 +419,7 @@ public class MainViewModel : ViewModelBase
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
ScheduleSave();
AppLog.Write("LoadLayout end");
}
public void Shutdown()
+12
View File
@@ -0,0 +1,12 @@
# ViewModels — index
MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) |
| `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
+14 -7
View File
@@ -1,5 +1,9 @@
# ytLlive — AI Guide
> Memory map entry point. Conventions live in [`schema.md`](schema.md); task
> status and YouTube API research in [`TASKS.md`](TASKS.md); directory maps in
> each folder's `index.md`. Reading order: this file → `TASKS.md` → `<dir>/index.md` → source.
## Run
```bash
@@ -19,10 +23,11 @@ C# / WPF (.NET 8) following MVVM:
| Path | Role |
|------|------|
| `Models/` | Plain data types — Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI |
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
### Key patterns
@@ -32,16 +37,18 @@ C# / WPF (.NET 8) following MVVM:
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Resolution tiers (bottom bar): 1080p60@8 → 1080p30@8 → 720p60@6 → 480p30@2.5 Mbps; default = first. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable)
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
### Current limitations / TODOs
- `OAuthCredentials.ClientId` / `ClientSecret` in `Helpers/OAuthCredentials.cs` are empty — the app
owner fills them in once (developer task, baked into the binary; creators never configure anything)
- `Helpers/OAuthCredentials.cs` now contains the real ClientId/ClientSecret — auth service is implemented, but **tokens still don't persist** (Windows DPAPI planned; account UI in the GoLive dialog is simulated)
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming
- No token persistence yet (Windows DPAPI planned) — scene/source/asset layout *does* persist (SQLite)
- Scene/source/asset layout *does* persist (SQLite); token persistence does not (yet)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- No capture/encoding/RTMP yet
- Stream config (title/description/visibility/quality) still in-memory
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
## Design Principle
+54
View File
@@ -0,0 +1,54 @@
# ytLlive — Memory Map (schema)
This file defines how the repo stores durable, associative memory: context as
plain markdown files, versioned in git. It is the map of the map — the schema
that every other memory file follows. Read `ai.md` first; this file explains
*why* and *how*.
## Why markdown files, not a database
- Grep-able, diffable, portable, human-editable — no tooling lock-in.
- Git gives versioning and history for free; every stale fact is a `git log` away.
- Files form a graph: each file is a node, each relative link an edge. The map
stays associative (like memory) rather than a rigid tree.
## File inventory
| File | Role | Entry point? |
|------|------|--------------|
| `README.md` | Human-facing intro: what the app is, how to run it, roadmap | no |
| `ai.md` | **AI guide + session handoff** — architecture, patterns, decisions, the cognitive map home | **yes — start here** |
| `TASKS.md` | Task queue + authoritative YouTube API research facts + task statuses | yes — for status |
| `schema.md` | This file: the conventions below | when in doubt |
| `<dir>/index.md` | Per-directory map (progressive disclosure): what lives there + links | when diving into code |
| `Views/` | Reserved for Views; currently empty | — |
## Conventions
1. **Semantic filenames.** A file's name says what it is (`YouTubeAuthService.cs`,
`FocusPreservingListBox.cs`). No codes, no abbreviations. Naming beats search.
2. **One `index.md` per code directory.** The index is a Map of Content (MOC):
it lists every file in that directory with a one-line purpose and links out to
related directories. Details live in the files, never duplicated in the index.
3. **Links over hierarchy.** Prefer relative links between related files over
deep nesting. Relationships that matter should be visible from `ai.md` or an
`index.md`; a note that is unreachable from `ai.md` might as well not exist.
4. **Stale facts get FIXED, not appended.** A wrong map is worse than no map.
When the code changes, update the memory file in the same change — no
"TODO: update later" notes.
5. **Progressive disclosure.** Don't cram detail into `ai.md`. `ai.md` names
concepts and points at files; the indexes and source are the detail. Each
layer adds resolution only when you drill in.
## Reading order
1. `ai.md` — what this app is, how it's built, current state, open decisions.
2. `TASKS.md` — what's done, what's next, and the YouTube API constraints.
3. `<dir>/index.md` — to understand a directory before reading its files.
4. The source files themselves — always the authority over any memory file.
## Integration rule
Every feature change ships with its memory update: `ai.md` for architecture /
patterns, `TASKS.md` for status, index files when the layout changes. That is
what keeps the map accurate enough to trust next session.